diff --git a/.env.example b/.env.example index d8a7547d..08dda83e 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ DB_DRIVER=postgres POSTGRES_USER=admin POSTGRES_PASSWORD=password -POSTGRES_DB=ocotillo +POSTGRES_DB=ocotilloapi_dev POSTGRES_HOST=localhost POSTGRES_PORT=5432 diff --git a/.github/app.template.yaml b/.github/app.template.yaml index 44df2f86..2ed7342a 100644 --- a/.github/app.template.yaml +++ b/.github/app.template.yaml @@ -1,8 +1,13 @@ service: ${SERVICE_NAME} runtime: python313 -entrypoint: gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app -instance_class: F4 +entrypoint: ${ENTRYPOINT} service_account: "${CLOUD_SQL_USER}.gserviceaccount.com" +instance_class: F4 +inbound_services: + - warmup +automatic_scaling: + min_instances: ${MIN_INSTANCES} + max_instances: ${MAX_INSTANCES} handlers: - url: /.* secure: always diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 40fbd0e4..96f10356 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -8,7 +8,7 @@ permissions: contents: write jobs: - staging-deploy: + production-deploy: runs-on: ubuntu-latest environment: production @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 - name: Install uv in container - uses: astral-sh/setup-uv@v7.3.1 + uses: astral-sh/setup-uv@v8.0.0 with: version: "latest" @@ -47,6 +47,16 @@ jobs: run: | uv run alembic upgrade head + - name: Refresh materialized views on production database + env: + DB_DRIVER: "cloudsql" + CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" + CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" + CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + CLOUD_SQL_IAM_AUTH: true + run: | + uv run python -m cli.cli refresh-pygeoapi-materialized-views + - name: Ensure envsubst is available run: | if ! command -v envsubst >/dev/null 2>&1; then @@ -54,9 +64,8 @@ jobs: sudo apt-get install -y gettext-base fi - - name: Render app.yaml + - name: Render App Engine configs env: - SERVICE_NAME: "ocotillo-api" ENVIRONMENT: "production" CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" @@ -77,25 +86,59 @@ jobs: SESSION_SECRET_KEY: "${{ secrets.SESSION_SECRET_KEY }}" APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}" run: | + export MAX_INSTANCES="10" + export SERVICE_NAME="ocotillo-api" + export ENTRYPOINT="gunicorn -w 1 -k uvicorn.workers.UvicornWorker main:app" + export MIN_INSTANCES="0" envsubst < .github/app.template.yaml > app.yaml - name: Deploy to Google Cloud run: | - gcloud app deploy app.yaml --quiet --project ${{ vars.GCP_PROJECT_ID }} + gcloud app deploy \ + app.yaml \ + --quiet \ + --project ${{ vars.GCP_PROJECT_ID }} - # Clean up old versions - delete only the oldest version, one created and one destroyed - - name: Clean up oldest version + - name: Clean up oldest versions run: | - OLDEST_VERSION=$(gcloud app versions list --service=ocotillo-api --project=${{ vars.GCP_PROJECT_ID}} --format="value(id)" --sort-by="version.createTime" | head -n 1) - if [ ! -z "$OLDEST_VERSION" ]; then - echo "Deleting oldest version: $OLDEST_VERSION" - gcloud app versions delete $OLDEST_VERSION --service=ocotillo-api --project=${{ vars.GCP_PROJECT_ID }} --quiet - echo "Deleted oldest version: $OLDEST_VERSION" + SERVICE="ocotillo-api" + VERSIONS_JSON="$(gcloud app versions list --service="$SERVICE" --project=${{ vars.GCP_PROJECT_ID }} --format=json --sort-by="version.createTime" 2>/dev/null || printf '[]')" + export VERSIONS_JSON + DELETE_VERSION="$(python - <<'PY' + import json + import os + + versions = json.loads(os.environ.get("VERSIONS_JSON", "[]") or "[]") + if len(versions) <= 1: + print("") + raise SystemExit(0) + + def traffic_split(version): + for key in ("traffic_split", "trafficSplit"): + value = version.get(key) + if value is not None: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + return 0.0 + + for version in versions: + if traffic_split(version) == 0.0: + print(version.get("id", "")) + break + else: + print("") + PY + )" + if [ -n "$DELETE_VERSION" ]; then + echo "Deleting old non-serving version for $SERVICE: $DELETE_VERSION" + gcloud app versions delete "$DELETE_VERSION" --service="$SERVICE" --project=${{ vars.GCP_PROJECT_ID }} --quiet else - echo "No versions to delete" + echo "No old non-serving versions to delete for $SERVICE" fi - - name: Remove app.yaml + - name: Remove rendered configs run: | rm app.yaml @@ -108,5 +151,5 @@ jobs: # ":" are not alloed in git tags, so replace with "-" - name: Tag commit run: | - git tag -a "production-deploy-$(date -u +%Y-%m-%d)T$(date -u +%H-%M-%S%z)" -m "staging gcloud deployment: $(date -u +%Y-%m-%d)T$(date -u +%H:%M:%S%z)" + git tag -a "production-deploy-$(date -u +%Y-%m-%d)T$(date -u +%H-%M-%S%z)" -m "production gcloud deployment: $(date -u +%Y-%m-%d)T$(date -u +%H:%M:%S%z)" git push origin --tags diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 0596a5f6..ac800253 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 - name: Install uv in container - uses: astral-sh/setup-uv@v7.3.1 + uses: astral-sh/setup-uv@v8.0.0 with: version: "latest" @@ -47,6 +47,16 @@ jobs: run: | uv run alembic upgrade head + - name: Refresh materialized views on staging database + env: + DB_DRIVER: "cloudsql" + CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" + CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" + CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + CLOUD_SQL_IAM_AUTH: true + run: | + uv run python -m cli.cli refresh-pygeoapi-materialized-views + - name: Ensure envsubst is available run: | if ! command -v envsubst >/dev/null 2>&1; then @@ -54,9 +64,8 @@ jobs: sudo apt-get install -y gettext-base fi - - name: Render app.yaml + - name: Render App Engine configs env: - SERVICE_NAME: "ocotillo-api-staging" ENVIRONMENT: "staging" CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" @@ -77,25 +86,59 @@ jobs: SESSION_SECRET_KEY: "${{ secrets.SESSION_SECRET_KEY }}" APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}" run: | + export MAX_INSTANCES="10" + export SERVICE_NAME="ocotillo-api-staging" + export ENTRYPOINT="gunicorn -w 1 -k uvicorn.workers.UvicornWorker main:app" + export MIN_INSTANCES="0" envsubst < .github/app.template.yaml > app.yaml - name: Deploy to Google Cloud run: | - gcloud app deploy app.yaml --quiet --project ${{ vars.GCP_PROJECT_ID }} + gcloud app deploy \ + app.yaml \ + --quiet \ + --project ${{ vars.GCP_PROJECT_ID }} - # Clean up old versions - delete only the oldest version, one created and one destroyed - - name: Clean up oldest version + - name: Clean up oldest versions run: | - OLDEST_VERSION=$(gcloud app versions list --service=ocotillo-api-staging --project=${{ vars.GCP_PROJECT_ID}} --format="value(id)" --sort-by="version.createTime" | head -n 1) - if [ ! -z "$OLDEST_VERSION" ]; then - echo "Deleting oldest version: $OLDEST_VERSION" - gcloud app versions delete $OLDEST_VERSION --service=ocotillo-api-staging --project=${{ vars.GCP_PROJECT_ID }} --quiet - echo "Deleted oldest version: $OLDEST_VERSION" + SERVICE="ocotillo-api-staging" + VERSIONS_JSON="$(gcloud app versions list --service="$SERVICE" --project=${{ vars.GCP_PROJECT_ID }} --format=json --sort-by="version.createTime" 2>/dev/null || printf '[]')" + export VERSIONS_JSON + DELETE_VERSION="$(python - <<'PY' + import json + import os + + versions = json.loads(os.environ.get("VERSIONS_JSON", "[]") or "[]") + if len(versions) <= 1: + print("") + raise SystemExit(0) + + def traffic_split(version): + for key in ("traffic_split", "trafficSplit"): + value = version.get(key) + if value is not None: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + return 0.0 + + for version in versions: + if traffic_split(version) == 0.0: + print(version.get("id", "")) + break + else: + print("") + PY + )" + if [ -n "$DELETE_VERSION" ]; then + echo "Deleting old non-serving version for $SERVICE: $DELETE_VERSION" + gcloud app versions delete "$DELETE_VERSION" --service="$SERVICE" --project=${{ vars.GCP_PROJECT_ID }} --quiet else - echo "No versions to delete" + echo "No old non-serving versions to delete for $SERVICE" fi - - name: Remove app.yaml + - name: Remove rendered configs run: | rm app.yaml diff --git a/.github/workflows/dependabot_automerge.yml b/.github/workflows/dependabot_automerge.yml index e63bf81d..96f84045 100644 --- a/.github/workflows/dependabot_automerge.yml +++ b/.github/workflows/dependabot_automerge.yml @@ -16,14 +16,14 @@ jobs: steps: - name: Fetch Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v2 + uses: dependabot/fetch-metadata@v3 with: github-token: ${{ secrets.GITHUB_TOKEN }} # Auto-approve (only matters if your branch protection requires reviews) - name: Approve PR if: steps.metadata.outputs.update-type != 'version-update:semver-major' - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/jira_codex_pr.yml b/.github/workflows/jira_codex_pr.yml index 7b885d5c..191c6b37 100644 --- a/.github/workflows/jira_codex_pr.yml +++ b/.github/workflows/jira_codex_pr.yml @@ -59,7 +59,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Set up uv (with cache) - uses: astral-sh/setup-uv@bd870193dd98cea382bc44a732c2e0d17379a16d # v4 + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v4 with: enable-cache: true diff --git a/.github/workflows/jira_issue_on_open.yml b/.github/workflows/jira_issue_on_open.yml index 4b13fcc0..99029c87 100644 --- a/.github/workflows/jira_issue_on_open.yml +++ b/.github/workflows/jira_issue_on_open.yml @@ -176,7 +176,7 @@ jobs: echo "jira_browse_url=${JIRA_BASE_URL}/browse/${JIRA_KEY}" >> "$GITHUB_OUTPUT" - name: Comment Jira link back on the GitHub issue - uses: actions/github-script@v8 + uses: actions/github-script@v9 env: JIRA_KEY: ${{ steps.jira.outputs.jira_key }} JIRA_URL: ${{ steps.jira.outputs.jira_browse_url }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 79bfcd7e..85c26684 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,15 +31,26 @@ jobs: SESSION_SECRET_KEY: supersecretkeyforunittests AUTHENTIK_DISABLE_AUTHENTICATION: 1 + services: + postgis: + image: postgis/postgis:17-3.5 + # don't test against latest. be explicit in version being tested to avoid breaking changes + # image: postgis/postgis:latest + env: + POSTGRES_PASSWORD: postgres + POSTGRES_PORT: 5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: - name: Check out source repository uses: actions/checkout@v6.0.2 - - name: Start database (PostGIS) - run: | - docker compose build db - docker compose up -d db - - name: Wait for database readiness run: | for i in {1..60}; do @@ -52,7 +63,7 @@ jobs: exit 1 - name: Install uv - uses: astral-sh/setup-uv@v7.3.1 + uses: astral-sh/setup-uv@v8.0.0 with: enable-cache: true cache-dependency-glob: uv.lock @@ -86,15 +97,11 @@ jobs: run: uv run pytest -vv --durations=20 --cov --cov-report=xml --junitxml=junit.xml --ignore=tests/transfers - name: Upload results to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} - - name: Stop database - if: always() - run: docker compose down -v - bdd-tests: runs-on: ubuntu-latest @@ -116,15 +123,26 @@ jobs: AUTHENTIK_DISABLE_AUTHENTICATION: 1 DROP_AND_REBUILD_DB: 1 + services: + postgis: + image: postgis/postgis:17-3.5 + # don't test against latest. be explicit in version being tested to avoid breaking changes + # image: postgis/postgis:latest + env: + POSTGRES_PASSWORD: postgres + POSTGRES_PORT: 5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: - name: Check out source repository uses: actions/checkout@v6.0.2 - - name: Start database (PostGIS) - run: | - docker compose build db - docker compose up -d db - - name: Wait for database readiness run: | for i in {1..60}; do @@ -137,7 +155,7 @@ jobs: exit 1 - name: Install uv - uses: astral-sh/setup-uv@v7.3.1 + uses: astral-sh/setup-uv@v8.0.0 with: enable-cache: true cache-dependency-glob: uv.lock @@ -169,7 +187,3 @@ jobs: - name: Run BDD tests run: uv run behave tests/features --tags="@backend and @production and not @skip" --no-capture - - - name: Stop database - if: always() - run: docker compose down -v diff --git a/AGENTS.MD b/AGENTS.MD index ae0bc08d..afeebcd9 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -25,6 +25,13 @@ these transfers, keep the following rules in mind to avoid hour-long runs: - Data migrations should be safe to re-run without creating duplicate rows or corrupting data. - Use upserts or duplicate checks and update source fields only after successful inserts. +## 4. Do a cleanup and code analysis pass after code changes +- After completing any code modification, do a cleanup and code analysis pass adjusted to the size and risk of the change. +- Check for obvious regressions, dead code, inconsistent config/docs/tests, and adjacent issues introduced by the change. +- Fix any concrete issues you find in that pass instead of stopping at implementation. +- After code cleanup, run `black` on the touched Python files and run `flake8` on the touched Python files before wrapping up. +- Run targeted validation for the modified area after cleanup; use broader validation when the change affects shared boot, deploy, or database paths. + Following this playbook keeps ETL runs measured in seconds/minutes instead of hours. EOF ## Activate python venv diff --git a/README.md b/README.md index 155dc2b9..d9a42a32 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ supports research, field operations, and public data delivery for the Bureau of ## 🗺️ OGC API - Features The API exposes OGC API - Features endpoints under `/ogcapi` using `pygeoapi`. +In App Engine deployments, `/admin` and `/ogcapi` are served from the same +application as the primary API. The service is intended to scale to zero +outside business hours and be kept warm during the workday with Cloud Scheduler +hits to `/_ah/warmup`. ### Landing & metadata @@ -140,6 +144,36 @@ Notes: * Create file gcs_credentials.json in the root directory of the project, and obtain its contents from a teammate. * PostgreSQL uses the default port 5432. +Minimum vars to set in `.env` for local development: +* `POSTGRES_USER` +* `POSTGRES_PASSWORD` +* `POSTGRES_DB` (`ocotilloapi_dev` when using Docker Compose dev) +* `POSTGRES_HOST` (`localhost` for local psql/pytest against mapped Docker port) +* `POSTGRES_PORT` (`5432`) +* `MODE` (`development` recommended locally) +* `SESSION_SECRET_KEY` (required if you want to use `/admin`) + +Auth-related vars (required when auth is enabled, optional when `AUTHENTIK_DISABLE_AUTHENTICATION=1`): +* `AUTHENTIK_DISABLE_AUTHENTICATION` +* `AUTHENTIK_URL` +* `AUTHENTIK_CLIENT_ID` +* `AUTHENTIK_AUTHORIZE_URL` +* `AUTHENTIK_TOKEN_URL` + +pygeoapi vars: +* `PYGEOAPI_MOUNT_PATH` (default `/ogcapi`) +* `PYGEOAPI_RUNTIME_DIR` (default `/tmp/pygeoapi`) +* `PYGEOAPI_POSTGRES_HOST` +* `PYGEOAPI_POSTGRES_PORT` +* `PYGEOAPI_POSTGRES_DB` +* `PYGEOAPI_POSTGRES_USER` +* `PYGEOAPI_POSTGRES_PASSWORD` + +Optional telemetry vars: +* `SENTRY_DSN` +* `APITALLY_CLIENT_ID` +* `ENVIRONMENT` + In development set `MODE=development` to allow lexicon enums to be populated. When `MODE=development`, the app attempts to seed the database with 10 example records via `transfers/seed.py`; if a `contact` record already exists, the seed step is skipped. #### 5. Database and server @@ -169,9 +203,19 @@ docker compose up --build Notes: * Requires Docker Desktop. -* Spins up two containers: `db` (PostGIS/PostgreSQL) and `app` (FastAPI API service). -* `alembic upgrade head` runs on app startup after `docker compose up`. -* The database listens on port `5432` both inside the container and on your host. Ensure `POSTGRES_PORT=5432` in your `.env` to run local commands against the Docker DB (e.g., `uv run pytest`, `uv run python -m transfers.transfer`). +* By default, spins up two containers: + * `db` for PostGIS/PostgreSQL + * `app` for the primary API, admin UI, and OGC API on `http://localhost:8000` +* `db` initializes both application databases in the same Postgres service: + * `ocotilloapi_dev` + * `ocotilloapi_test` +* `alembic upgrade head` runs in the `app` container on startup. +* Compose uses hardcoded DB names: + * dev: `ocotilloapi_dev` + * test: `ocotilloapi_test` (created by init SQL in `docker/db/init/01-create-test-db.sql`) +* The database listens on port `5432` both inside the container and on your host. Ensure `POSTGRES_PORT=5432` and `POSTGRES_DB=ocotilloapi_dev` in your `.env` to run local commands against the Docker dev DB (e.g., `uv run pytest`, `uv run python -m transfers.transfer`). +* To restore a local or GCS-backed SQL dump into your local target DB, run `source .venv/bin/activate && python -m cli.cli restore-local-db path/to/dump.sql` or `source .venv/bin/activate && python -m cli.cli restore-local-db gs://ocotillo/sql-exports/latest.sql.gz`. +* `SESSION_SECRET_KEY` only needs to be set in `.env` if you plan to use `/admin`; without it, the API and `/ogcapi` still boot, but `/admin` will be unavailable. #### Staging Data diff --git a/alembic/env.py b/alembic/env.py index 62deed2d..944f00e1 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -7,7 +7,7 @@ from dotenv import load_dotenv from sqlalchemy import create_engine, engine_from_config, pool, text -from services.util import get_bool_env +from services.env import get_bool_env # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py b/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py new file mode 100644 index 00000000..a70edaf0 --- /dev/null +++ b/alembic/versions/b6f7a8b9c0d1_add_normalized_chemistry_results_materialized_view.py @@ -0,0 +1,298 @@ +"""add normalized chemistry results materialized view + +Revision ID: b6f7a8b9c0d1 +Revises: l5e6f7a8b9c0 +Create Date: 2026-03-04 14:10:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "b6f7a8b9c0d1" +down_revision: Union[str, Sequence[str], None] = "l5e6f7a8b9c0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + +# Static analyte columns for major chemistry pivots. +# Includes aliases observed in current DB values (e.g., Ca(total), IONBAL, TAn, TCat, Na+K). +STATIC_ANALYTE_COLUMNS: list[tuple[str, str]] = [ + ("tds", "tds"), + ("calcium", "calcium"), + ("calcium_total", "calcium_total"), + ("magnesium", "magnesium"), + ("magnesium_total", "magnesium_total"), + ("sodium", "sodium"), + ("sodium_total", "sodium_total"), + ("potassium", "potassium"), + ("potassium_total", "potassium_total"), + ("sodium_plus_potassium", "sodium_plus_potassium"), + ("bicarbonate", "bicarbonate"), + ("carbonate", "carbonate"), + ("sulfate", "sulfate"), + ("chloride", "chloride"), + ("ion_balance", "ion_balance"), + ("total_anions", "total_anions"), + ("total_cations", "total_cations"), + ("alkalinity", "alkalinity"), + ("hardness", "hardness"), + ("specific_conductance", "specific_conductance"), + ("ph", "ph"), + ("nitrate", "nitrate"), + ("fluoride", "fluoride"), + ("silica", "silica"), +] + + +def _static_analyte_select_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.sample_value) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS + ] + ) + + +def _static_analyte_unit_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.units) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS + ] + ) + + +def _create_major_chemistry_results_view() -> str: + static_columns = _static_analyte_select_columns() + static_unit_columns = _static_analyte_unit_columns() + return f""" + CREATE MATERIALIZED VIEW ogc_major_chemistry_results AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + chemistry_rows AS ( + SELECT + csi.thing_id, + mc.id AS result_id, + COALESCE(mc."AnalysisDate", csi."CollectionDate") AS observation_datetime, + trim(mc."Analyte") AS analyte_name, + trim(mc."Symbol") AS symbol_name, + mc."SampleValue"::double precision AS sample_value, + mc."Units" AS units + FROM "NMA_MajorChemistry" AS mc + JOIN "NMA_Chemistry_SampleInfo" AS csi + ON csi.id = mc.chemistry_sample_info_id + JOIN thing AS t + ON t.id = csi.thing_id + WHERE mc."SampleValue" IS NOT NULL + AND t.thing_type = 'water well' + ), + normalized_rows AS ( + SELECT + cr.thing_id, + cr.result_id, + cr.observation_datetime, + NULLIF( + regexp_replace( + lower(trim(coalesce(cr.analyte_name, ''))), + '[^a-z0-9]+', + '', + 'g' + ), + '' + ) AS analyte_token, + NULLIF( + regexp_replace( + lower(trim(coalesce(cr.symbol_name, ''))), + '[^a-z0-9]+', + '', + 'g' + ), + '' + ) AS symbol_token, + cr.sample_value, + cr.units + FROM chemistry_rows AS cr + ), + mapped_rows AS ( + SELECT + nr.thing_id, + nr.result_id, + nr.observation_datetime, + CASE + WHEN coalesce(nr.symbol_token, '') = 'tds' + OR coalesce(nr.analyte_token, '') IN ('tds', 'totaldissolvedsolids') + THEN 'tds' + + WHEN coalesce(nr.symbol_token, '') = 'ca' + OR coalesce(nr.analyte_token, '') = 'ca' + THEN 'calcium' + WHEN coalesce(nr.analyte_token, '') = 'catotal' + THEN 'calcium_total' + + WHEN coalesce(nr.symbol_token, '') = 'mg' + OR coalesce(nr.analyte_token, '') = 'mg' + THEN 'magnesium' + WHEN coalesce(nr.analyte_token, '') = 'mgtotal' + THEN 'magnesium_total' + + WHEN coalesce(nr.symbol_token, '') = 'na' + OR coalesce(nr.analyte_token, '') = 'na' + THEN 'sodium' + WHEN coalesce(nr.analyte_token, '') = 'natotal' + THEN 'sodium_total' + + WHEN coalesce(nr.symbol_token, '') = 'k' + OR coalesce(nr.analyte_token, '') = 'k' + THEN 'potassium' + WHEN coalesce(nr.analyte_token, '') = 'ktotal' + THEN 'potassium_total' + + WHEN coalesce(nr.analyte_token, '') = 'nak' + THEN 'sodium_plus_potassium' + + WHEN coalesce(nr.symbol_token, '') = 'hco3' + OR coalesce(nr.analyte_token, '') = 'hco3' + THEN 'bicarbonate' + WHEN coalesce(nr.symbol_token, '') = 'co3' + OR coalesce(nr.analyte_token, '') = 'co3' + THEN 'carbonate' + WHEN coalesce(nr.symbol_token, '') = 'so4' + OR coalesce(nr.analyte_token, '') = 'so4' + THEN 'sulfate' + WHEN coalesce(nr.symbol_token, '') = 'cl' + OR coalesce(nr.analyte_token, '') = 'cl' + THEN 'chloride' + + WHEN coalesce(nr.analyte_token, '') = 'ionbal' + THEN 'ion_balance' + WHEN coalesce(nr.analyte_token, '') = 'tan' + THEN 'total_anions' + WHEN coalesce(nr.analyte_token, '') = 'tcat' + THEN 'total_cations' + + WHEN coalesce(nr.analyte_token, '') IN ('alk', 'alkalinity') + THEN 'alkalinity' + WHEN coalesce(nr.analyte_token, '') IN ('hrd', 'hardness') + THEN 'hardness' + WHEN coalesce(nr.analyte_token, '') IN ( + 'condlab', + 'specificconductance', + 'specificconductivity', + 'conductivity' + ) + THEN 'specific_conductance' + WHEN coalesce(nr.symbol_token, '') = 'ph' + OR coalesce(nr.analyte_token, '') IN ('ph', 'phl') + THEN 'ph' + + WHEN coalesce(nr.symbol_token, '') = 'no3' + OR coalesce(nr.analyte_token, '') IN ('no3', 'nitrate') + THEN 'nitrate' + WHEN coalesce(nr.symbol_token, '') = 'f' + OR coalesce(nr.analyte_token, '') IN ('f', 'fluoride') + THEN 'fluoride' + WHEN coalesce(nr.symbol_token, '') = 'sio2' + OR coalesce(nr.analyte_token, '') IN ('sio2', 'silica') + THEN 'silica' + + ELSE NULL + END AS analyte_key, + nr.sample_value, + nr.units + FROM normalized_rows AS nr + ), + latest_results AS ( + SELECT + mr.thing_id, + mr.analyte_key, + mr.sample_value, + mr.units, + mr.observation_datetime, + ROW_NUMBER() OVER ( + PARTITION BY mr.thing_id, mr.analyte_key + ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC + ) AS rn + FROM mapped_rows AS mr + WHERE mr.analyte_key IS NOT NULL + ) + SELECT + t.id AS id, + ll.location_id, + t.name, + t.thing_type, + COUNT(*)::integer AS analyte_count, + MAX(lr.observation_datetime::date) AS latest_chemistry_date, +{static_columns}, +{static_unit_columns}, + l.point + FROM latest_results AS lr + JOIN thing AS t ON t.id = lr.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lr.rn = 1 + GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + required_tables = { + "thing", + "location", + "location_thing_association", + "NMA_Chemistry_SampleInfo", + "NMA_MajorChemistry", + } + + if not required_tables.issubset(existing_tables): + missing = sorted(t for t in required_tables if t not in existing_tables) + raise RuntimeError( + "Cannot create ogc_major_chemistry_results. Missing required tables: " + + ", ".join(missing) + ) + + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_major_chemistry_results")) + op.execute( + text("DROP MATERIALIZED VIEW IF EXISTS ogc_normalized_chemistry_results") + ) + op.execute(text(_create_major_chemistry_results_view())) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_major_chemistry_results IS " + "'Latest major-chemistry analyte values per location, pivoted into static analyte columns.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_major_chemistry_results_id " + "ON ogc_major_chemistry_results (id)" + ) + ) + + +def downgrade() -> None: + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_major_chemistry_results")) + op.execute( + text("DROP MATERIALIZED VIEW IF EXISTS ogc_normalized_chemistry_results") + ) diff --git a/alembic/versions/c7f8a9b0c1d2_add_thing_id_to_nma_surface_water_data.py b/alembic/versions/c7f8a9b0c1d2_add_thing_id_to_nma_surface_water_data.py index 8a359768..02161549 100644 --- a/alembic/versions/c7f8a9b0c1d2_add_thing_id_to_nma_surface_water_data.py +++ b/alembic/versions/c7f8a9b0c1d2_add_thing_id_to_nma_surface_water_data.py @@ -44,7 +44,10 @@ def upgrade() -> None: # Remove any rows that cannot be linked to a Thing, then enforce NOT NULL op.execute('DELETE FROM "NMA_SurfaceWaterData" WHERE thing_id IS NULL') op.alter_column( - "NMA_SurfaceWaterData", "thing_id", existing_type=sa.Integer(), nullable=False + "NMA_SurfaceWaterData", + "thing_id", + existing_type=sa.Integer(), + nullable=False, ) diff --git a/alembic/versions/c7f8a9b0d1e2_add_minor_chemistry_wells_materialized_view.py b/alembic/versions/c7f8a9b0d1e2_add_minor_chemistry_wells_materialized_view.py new file mode 100644 index 00000000..e2e014ac --- /dev/null +++ b/alembic/versions/c7f8a9b0d1e2_add_minor_chemistry_wells_materialized_view.py @@ -0,0 +1,319 @@ +"""add minor chemistry wells materialized view + +Revision ID: c7f8a9b0d1e2 +Revises: b6f7a8b9c0d1 +Create Date: 2026-03-04 16:20:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "c7f8a9b0d1e2" +down_revision: Union[str, Sequence[str], None] = "b6f7a8b9c0d1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + +STATIC_ANALYTE_COLUMNS: list[tuple[str, str]] = [ + ("h2r", "h2r"), + ("o18r", "o18r"), + ("c13r", "c13r"), + ("c14", "c14"), + ("c14_years", "c14_years"), + ("fluoride", "fluoride"), + ("barium", "barium"), + ("barium_total", "barium_total"), + ("copper", "copper"), + ("copper_total", "copper_total"), + ("zinc", "zinc"), + ("zinc_total", "zinc_total"), + ("molybdenum", "molybdenum"), + ("molybdenum_total", "molybdenum_total"), + ("silica", "silica"), + ("silicon", "silicon"), + ("silicon_total", "silicon_total"), + ("manganese", "manganese"), + ("manganese_total", "manganese_total"), + ("iron", "iron"), + ("iron_total", "iron_total"), + ("strontium", "strontium"), + ("strontium_total", "strontium_total"), + ("chromium", "chromium"), + ("chromium_total", "chromium_total"), + ("boron", "boron"), + ("boron_total", "boron_total"), + ("uranium", "uranium"), + ("uranium_total", "uranium_total"), + ("lithium", "lithium"), + ("lithium_total", "lithium_total"), + ("silver", "silver"), + ("silver_total", "silver_total"), + ("antimony", "antimony"), + ("antimony_total", "antimony_total"), + ("beryllium", "beryllium"), + ("beryllium_total", "beryllium_total"), + ("lead", "lead"), + ("lead_total", "lead_total"), + ("thallium", "thallium"), + ("thallium_total", "thallium_total"), + ("bromide", "bromide"), + ("selenium", "selenium"), + ("selenium_total", "selenium_total"), + ("vanadium", "vanadium"), + ("vanadium_total", "vanadium_total"), + ("aluminum", "aluminum"), + ("aluminum_total", "aluminum_total"), + ("arsenic", "arsenic"), + ("arsenic_total", "arsenic_total"), + ("nickel", "nickel"), + ("nickel_total", "nickel_total"), + ("cadmium", "cadmium"), + ("cadmium_total", "cadmium_total"), + ("cobalt", "cobalt"), + ("cobalt_total", "cobalt_total"), + ("phosphate", "phosphate"), + ("nitrite", "nitrite"), + ("nitrate", "nitrate"), + ("nitrate_as_n", "nitrate_as_n"), + ("thorium", "thorium"), + ("thorium_total", "thorium_total"), + ("tin", "tin"), + ("tin_total", "tin_total"), + ("mercury", "mercury"), + ("mercury_total", "mercury_total"), + ("titanium", "titanium"), + ("titanium_total", "titanium_total"), +] + + +def _static_analyte_value_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.sample_value) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS + ] + ) + + +def _static_analyte_unit_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.units) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS + ] + ) + + +def _create_minor_chemistry_wells_view() -> str: + value_columns = _static_analyte_value_columns() + unit_columns = _static_analyte_unit_columns() + + return f""" + CREATE MATERIALIZED VIEW ogc_minor_chemistry_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + chemistry_rows AS ( + SELECT + csi.thing_id, + mtc.id AS result_id, + COALESCE(mtc.analysis_date::timestamp, csi."CollectionDate") AS observation_datetime, + trim(mtc.analyte) AS analyte_name, + mtc.sample_value::double precision AS sample_value, + mtc.units AS units + FROM "NMA_MinorTraceChemistry" AS mtc + JOIN "NMA_Chemistry_SampleInfo" AS csi + ON csi.id = mtc.chemistry_sample_info_id + JOIN thing AS t ON t.id = csi.thing_id + WHERE + mtc.sample_value IS NOT NULL + AND t.thing_type = 'water well' + ), + normalized_rows AS ( + SELECT + cr.thing_id, + cr.result_id, + cr.observation_datetime, + NULLIF( + regexp_replace( + lower(trim(coalesce(cr.analyte_name, ''))), + '[^a-z0-9]+', + '', + 'g' + ), + '' + ) AS analyte_token, + cr.sample_value, + cr.units + FROM chemistry_rows AS cr + ), + mapped_rows AS ( + SELECT + nr.thing_id, + nr.result_id, + nr.observation_datetime, + CASE + WHEN coalesce(nr.analyte_token, '') = 'h2r' THEN 'h2r' + WHEN coalesce(nr.analyte_token, '') = 'o18r' THEN 'o18r' + WHEN coalesce(nr.analyte_token, '') = 'c13r' THEN 'c13r' + WHEN coalesce(nr.analyte_token, '') = 'c14' THEN 'c14' + WHEN coalesce(nr.analyte_token, '') = 'c14years' THEN 'c14_years' + + WHEN coalesce(nr.analyte_token, '') = 'f' THEN 'fluoride' + WHEN coalesce(nr.analyte_token, '') = 'ba' THEN 'barium' + WHEN coalesce(nr.analyte_token, '') = 'batotal' THEN 'barium_total' + WHEN coalesce(nr.analyte_token, '') = 'cu' THEN 'copper' + WHEN coalesce(nr.analyte_token, '') = 'cutotal' THEN 'copper_total' + WHEN coalesce(nr.analyte_token, '') = 'zn' THEN 'zinc' + WHEN coalesce(nr.analyte_token, '') = 'zntotal' THEN 'zinc_total' + WHEN coalesce(nr.analyte_token, '') = 'mo' THEN 'molybdenum' + WHEN coalesce(nr.analyte_token, '') = 'mototal' THEN 'molybdenum_total' + WHEN coalesce(nr.analyte_token, '') = 'sio2' THEN 'silica' + WHEN coalesce(nr.analyte_token, '') = 'si' THEN 'silicon' + WHEN coalesce(nr.analyte_token, '') = 'sitotal' THEN 'silicon_total' + WHEN coalesce(nr.analyte_token, '') = 'mn' THEN 'manganese' + WHEN coalesce(nr.analyte_token, '') = 'mntotal' THEN 'manganese_total' + WHEN coalesce(nr.analyte_token, '') = 'fe' THEN 'iron' + WHEN coalesce(nr.analyte_token, '') = 'fetotal' THEN 'iron_total' + WHEN coalesce(nr.analyte_token, '') = 'sr' THEN 'strontium' + WHEN coalesce(nr.analyte_token, '') = 'srtotal' THEN 'strontium_total' + WHEN coalesce(nr.analyte_token, '') = 'cr' THEN 'chromium' + WHEN coalesce(nr.analyte_token, '') = 'crtotal' THEN 'chromium_total' + WHEN coalesce(nr.analyte_token, '') = 'b' THEN 'boron' + WHEN coalesce(nr.analyte_token, '') = 'btotal' THEN 'boron_total' + WHEN coalesce(nr.analyte_token, '') = 'u' THEN 'uranium' + WHEN coalesce(nr.analyte_token, '') = 'utotal' THEN 'uranium_total' + WHEN coalesce(nr.analyte_token, '') = 'li' THEN 'lithium' + WHEN coalesce(nr.analyte_token, '') = 'litotal' THEN 'lithium_total' + WHEN coalesce(nr.analyte_token, '') = 'ag' THEN 'silver' + WHEN coalesce(nr.analyte_token, '') = 'agtotal' THEN 'silver_total' + WHEN coalesce(nr.analyte_token, '') = 'sb' THEN 'antimony' + WHEN coalesce(nr.analyte_token, '') = 'sbtotal' THEN 'antimony_total' + WHEN coalesce(nr.analyte_token, '') = 'be' THEN 'beryllium' + WHEN coalesce(nr.analyte_token, '') = 'betotal' THEN 'beryllium_total' + WHEN coalesce(nr.analyte_token, '') = 'pb' THEN 'lead' + WHEN coalesce(nr.analyte_token, '') = 'pbtotal' THEN 'lead_total' + WHEN coalesce(nr.analyte_token, '') = 'tl' THEN 'thallium' + WHEN coalesce(nr.analyte_token, '') = 'tltotal' THEN 'thallium_total' + WHEN coalesce(nr.analyte_token, '') = 'br' THEN 'bromide' + WHEN coalesce(nr.analyte_token, '') = 'se' THEN 'selenium' + WHEN coalesce(nr.analyte_token, '') = 'setotal' THEN 'selenium_total' + WHEN coalesce(nr.analyte_token, '') = 'v' THEN 'vanadium' + WHEN coalesce(nr.analyte_token, '') = 'vtotal' THEN 'vanadium_total' + WHEN coalesce(nr.analyte_token, '') = 'al' THEN 'aluminum' + WHEN coalesce(nr.analyte_token, '') = 'altotal' THEN 'aluminum_total' + WHEN coalesce(nr.analyte_token, '') = 'as' THEN 'arsenic' + WHEN coalesce(nr.analyte_token, '') = 'astotal' THEN 'arsenic_total' + WHEN coalesce(nr.analyte_token, '') = 'ni' THEN 'nickel' + WHEN coalesce(nr.analyte_token, '') = 'nitotal' THEN 'nickel_total' + WHEN coalesce(nr.analyte_token, '') = 'cd' THEN 'cadmium' + WHEN coalesce(nr.analyte_token, '') = 'cdtotal' THEN 'cadmium_total' + WHEN coalesce(nr.analyte_token, '') = 'co' THEN 'cobalt' + WHEN coalesce(nr.analyte_token, '') = 'cototal' THEN 'cobalt_total' + WHEN coalesce(nr.analyte_token, '') = 'po4' THEN 'phosphate' + WHEN coalesce(nr.analyte_token, '') = 'no2' THEN 'nitrite' + WHEN coalesce(nr.analyte_token, '') = 'no3' THEN 'nitrate' + WHEN coalesce(nr.analyte_token, '') = 'no3n' THEN 'nitrate_as_n' + WHEN coalesce(nr.analyte_token, '') = 'th' THEN 'thorium' + WHEN coalesce(nr.analyte_token, '') = 'thtotal' THEN 'thorium_total' + WHEN coalesce(nr.analyte_token, '') = 'sn' THEN 'tin' + WHEN coalesce(nr.analyte_token, '') = 'sntotal' THEN 'tin_total' + WHEN coalesce(nr.analyte_token, '') = 'hg' THEN 'mercury' + WHEN coalesce(nr.analyte_token, '') = 'hgtotal' THEN 'mercury_total' + WHEN coalesce(nr.analyte_token, '') = 'ti' THEN 'titanium' + WHEN coalesce(nr.analyte_token, '') = 'titotal' THEN 'titanium_total' + ELSE NULL + END AS analyte_key, + nr.sample_value, + nr.units + FROM normalized_rows AS nr + ), + latest_results AS ( + SELECT + mr.thing_id, + mr.analyte_key, + mr.sample_value, + mr.units, + mr.observation_datetime, + ROW_NUMBER() OVER ( + PARTITION BY mr.thing_id, mr.analyte_key + ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC + ) AS rn + FROM mapped_rows AS mr + WHERE mr.analyte_key IS NOT NULL + ) + SELECT + t.id AS id, + ll.location_id, + t.name, + t.thing_type, + COUNT(*)::integer AS analyte_count, + MAX(lr.observation_datetime::date) AS latest_chemistry_date, +{value_columns}, +{unit_columns}, + l.point + FROM latest_results AS lr + JOIN thing AS t ON t.id = lr.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lr.rn = 1 + AND t.thing_type = 'water well' + GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + required_tables = { + "thing", + "location", + "location_thing_association", + "NMA_Chemistry_SampleInfo", + "NMA_MinorTraceChemistry", + } + + if not required_tables.issubset(existing_tables): + missing = sorted(t for t in required_tables if t not in existing_tables) + raise RuntimeError( + "Cannot create ogc_minor_chemistry_wells. Missing required tables: " + + ", ".join(missing) + ) + + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_minor_chemistry_wells")) + op.execute(text(_create_minor_chemistry_wells_view())) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_minor_chemistry_wells IS " + "'Latest minor/trace chemistry analyte values for water wells, pivoted into static analyte columns.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_minor_chemistry_wells_id " + "ON ogc_minor_chemistry_wells (id)" + ) + ) + + +def downgrade() -> None: + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_minor_chemistry_wells")) diff --git a/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py b/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py index e11bf240..60d03fc0 100644 --- a/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py +++ b/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py @@ -34,7 +34,10 @@ ("monitoring_wells", "monitoring well"), ("observation_wells", "observation well"), ("other_things", "other"), - ("outfalls_wastewater_return_flow", "outfall of wastewater or return flow"), + ( + "outfalls_wastewater_return_flow", + "outfall of wastewater or return flow", + ), ("perennial_streams", "perennial stream"), ("piezometers", "piezometer"), ("production_wells", "production well"), @@ -107,8 +110,11 @@ def _create_latest_depth_view() -> str: o.observation_datetime, o.value, o.measuring_point_height, - -- Treat NULL measuring_point_height as 0 when computing depth_to_water_bgs - (o.value - COALESCE(o.measuring_point_height, 0)) AS depth_to_water_bgs, + -- Treat NULL measuring_point_height as 0 when computing + -- depth_to_water_bgs. + ( + o.value - COALESCE(o.measuring_point_height, 0) + ) AS depth_to_water_bgs, ROW_NUMBER() OVER ( PARTITION BY fe.thing_id ORDER BY o.observation_datetime DESC, o.id DESC @@ -151,7 +157,10 @@ def _create_avg_tds_view() -> str: SELECT csi.thing_id, mc.id AS major_chemistry_id, - COALESCE(mc."AnalysisDate", csi."CollectionDate")::date AS observation_date, + COALESCE( + mc."AnalysisDate", + csi."CollectionDate" + )::date AS observation_date, mc."SampleValue" AS sample_value, mc."Units" AS units FROM "NMA_MajorChemistry" AS mc @@ -193,15 +202,16 @@ def _drop_view_or_materialized_view(view_name: str) -> None: def _create_matview_indexes() -> None: # Required so REFRESH MATERIALIZED VIEW CONCURRENTLY can run. + avg_tds_index_sql = ( + "CREATE UNIQUE INDEX ux_ogc_avg_tds_wells_id " "ON ogc_avg_tds_wells (id)" + ) op.execute( text( "CREATE UNIQUE INDEX ux_ogc_latest_depth_to_water_wells_id " "ON ogc_latest_depth_to_water_wells (id)" ) ) - op.execute( - text("CREATE UNIQUE INDEX ux_ogc_avg_tds_wells_id " "ON ogc_avg_tds_wells (id)") - ) + op.execute(text(avg_tds_index_sql)) def _create_refresh_function() -> str: @@ -220,7 +230,11 @@ def _create_refresh_function() -> str: WHERE schemaname = 'public' AND matviewname LIKE 'ogc_%' LOOP - matview_fqname := format('%I.%I', matview_record.schemaname, matview_record.matviewname); + matview_fqname := format( + '%I.%I', + matview_record.schemaname, + matview_record.matviewname + ); EXECUTE format('REFRESH MATERIALIZED VIEW %s', matview_fqname); END LOOP; END; @@ -235,10 +249,15 @@ def upgrade() -> None: required_core = {"thing", "location", "location_thing_association"} existing_tables = set(inspector.get_table_names(schema="public")) if not required_core.issubset(existing_tables): - missing_tables = sorted(t for t in required_core if t not in existing_tables) + missing_tables = sorted( + table_name + for table_name in required_core + if table_name not in existing_tables + ) missing_tables_str = ", ".join(missing_tables) raise RuntimeError( - "Cannot create pygeoapi supporting views. The following required core " + "Cannot create pygeoapi supporting views. " + "The following required core " f"tables are missing: {missing_tables_str}" ) @@ -255,7 +274,8 @@ def upgrade() -> None: ) missing_depth_tables_str = ", ".join(missing_depth_tables) raise RuntimeError( - "Cannot create ogc_latest_depth_to_water_wells. The following required " + "Cannot create ogc_latest_depth_to_water_wells. " + "The following required " f"tables are missing: {missing_depth_tables_str}" ) op.execute(text(_create_latest_depth_view())) @@ -269,7 +289,11 @@ def upgrade() -> None: _drop_view_or_materialized_view("ogc_avg_tds_wells") required_tds = {"NMA_MajorChemistry", "NMA_Chemistry_SampleInfo"} if not required_tds.issubset(existing_tables): - missing_tds_tables = sorted(t for t in required_tds if t not in existing_tables) + missing_tds_tables = sorted( + table_name + for table_name in required_tds + if table_name not in existing_tables + ) missing_tds_tables_str = ", ".join(missing_tds_tables) raise RuntimeError( "Cannot create ogc_avg_tds_wells. The following required " @@ -288,7 +312,10 @@ def upgrade() -> None: def downgrade() -> None: - op.execute(text(f"DROP FUNCTION IF EXISTS public.{REFRESH_FUNCTION_NAME}()")) + drop_refresh_function_sql = ( + f"DROP FUNCTION IF EXISTS public.{REFRESH_FUNCTION_NAME}()" + ) + op.execute(text(drop_refresh_function_sql)) _drop_view_or_materialized_view("ogc_avg_tds_wells") _drop_view_or_materialized_view("ogc_latest_depth_to_water_wells") for view_id, _ in THING_COLLECTIONS: diff --git a/alembic/versions/m6f7a8b9c0d1_add_water_elevation_materialized_view.py b/alembic/versions/m6f7a8b9c0d1_add_water_elevation_materialized_view.py new file mode 100644 index 00000000..4e0b1d15 --- /dev/null +++ b/alembic/versions/m6f7a8b9c0d1_add_water_elevation_materialized_view.py @@ -0,0 +1,116 @@ +"""add water elevation materialized view + +Revision ID: m6f7a8b9c0d1 +Revises: c7f8a9b0d1e2 +Create Date: 2026-03-09 10:45:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "m6f7a8b9c0d1" +down_revision: Union[str, Sequence[str], None] = "c7f8a9b0d1e2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + + +def _create_water_elevation_view() -> str: + return f""" + CREATE MATERIALIZED VIEW ogc_water_elevation_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + (o.value - COALESCE(o.measuring_point_height, 0)) + AS depth_to_water_below_ground_surface, + ROW_NUMBER() OVER ( + PARTITION BY fe.thing_id + ORDER BY o.observation_datetime DESC, o.id DESC + ) AS rn + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + ro.observation_id, + ro.observation_datetime, + l.elevation, + ro.depth_to_water_below_ground_surface, + ( + l.elevation - ro.depth_to_water_below_ground_surface + ) AS water_elevation, + l.point + FROM ranked_obs AS ro + JOIN thing AS t ON t.id = ro.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE ro.rn = 1 + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + required_tables = { + "thing", + "location", + "location_thing_association", + "observation", + "sample", + "field_activity", + "field_event", + } + + if not required_tables.issubset(existing_tables): + missing = sorted(t for t in required_tables if t not in existing_tables) + raise RuntimeError( + "Cannot create ogc_water_elevation_wells. Missing required tables: " + + ", ".join(missing) + ) + + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_water_elevation_wells")) + op.execute(text(_create_water_elevation_view())) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_water_elevation_wells IS " + "'Latest water elevation per well (elevation minus depth to water below ground surface).'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_water_elevation_wells_id " + "ON ogc_water_elevation_wells (id)" + ) + ) + + +def downgrade() -> None: + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_water_elevation_wells")) diff --git a/alembic/versions/n7a8b9c0d1e2_fix_water_elevation_units_to_feet.py b/alembic/versions/n7a8b9c0d1e2_fix_water_elevation_units_to_feet.py new file mode 100644 index 00000000..7a2b09bc --- /dev/null +++ b/alembic/versions/n7a8b9c0d1e2_fix_water_elevation_units_to_feet.py @@ -0,0 +1,199 @@ +"""fix water elevation units to feet + +Revision ID: n7a8b9c0d1e2 +Revises: m6f7a8b9c0d1 +Create Date: 2026-03-10 11:10:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "n7a8b9c0d1e2" +down_revision: Union[str, Sequence[str], None] = "m6f7a8b9c0d1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +METERS_TO_FEET = 3.28084 + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + + +def _create_water_elevation_view() -> str: + return f""" + CREATE MATERIALIZED VIEW ogc_water_elevation_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + CASE + WHEN lower(trim(o.unit)) IN ('m', 'meter', 'meters', 'metre', 'metres') THEN + (o.value * {METERS_TO_FEET}) - COALESCE(o.measuring_point_height, 0) + WHEN lower(trim(o.unit)) IN ('ft', 'foot', 'feet') THEN + o.value - COALESCE(o.measuring_point_height, 0) + ELSE + NULL + END AS depth_to_water_below_ground_surface + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL + AND lower(trim(o.unit)) IN ( + 'm', + 'meter', + 'meters', + 'metre', + 'metres', + 'ft', + 'foot', + 'feet' + ) + ), + latest_obs AS ( + SELECT + ro.*, + ROW_NUMBER() OVER ( + PARTITION BY ro.thing_id + ORDER BY ro.observation_datetime DESC, ro.observation_id DESC + ) AS rn + FROM ranked_obs AS ro + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + lo.observation_id, + lo.observation_datetime, + l.elevation AS elevation_m, + lo.depth_to_water_below_ground_surface AS depth_to_water_below_ground_surface_ft, + ((l.elevation * {METERS_TO_FEET}) - lo.depth_to_water_below_ground_surface) + AS water_elevation_ft, + l.point + FROM latest_obs AS lo + JOIN thing AS t ON t.id = lo.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lo.rn = 1 + """ + + +def _create_water_elevation_view_m6() -> str: + return f""" + CREATE MATERIALIZED VIEW ogc_water_elevation_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + (o.value - COALESCE(o.measuring_point_height, 0)) + AS depth_to_water_below_ground_surface, + ROW_NUMBER() OVER ( + PARTITION BY fe.thing_id + ORDER BY o.observation_datetime DESC, o.id DESC + ) AS rn + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + ro.observation_id, + ro.observation_datetime, + l.elevation, + ro.depth_to_water_below_ground_surface, + ( + l.elevation - ro.depth_to_water_below_ground_surface + ) AS water_elevation, + l.point + FROM ranked_obs AS ro + JOIN thing AS t ON t.id = ro.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE ro.rn = 1 + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + required_tables = { + "thing", + "location", + "location_thing_association", + "observation", + "sample", + "field_activity", + "field_event", + } + + if not required_tables.issubset(existing_tables): + missing = sorted(t for t in required_tables if t not in existing_tables) + raise RuntimeError( + "Cannot create ogc_water_elevation_wells. Missing required tables: " + + ", ".join(missing) + ) + + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_water_elevation_wells")) + op.execute(text(_create_water_elevation_view())) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_water_elevation_wells IS " + "'Latest water elevation per well with explicit units: elevation_m, depth_to_water_below_ground_surface_ft, water_elevation_ft.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_water_elevation_wells_id " + "ON ogc_water_elevation_wells (id)" + ) + ) + + +def downgrade() -> None: + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_water_elevation_wells")) + op.execute(text(_create_water_elevation_view_m6())) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_water_elevation_wells IS " + "'Latest water elevation per well (elevation minus depth to water below ground surface).'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_water_elevation_wells_id " + "ON ogc_water_elevation_wells (id)" + ) + ) diff --git a/alembic/versions/o8b9c0d1e2f3_rebuild_water_elevation_materialized_view.py b/alembic/versions/o8b9c0d1e2f3_rebuild_water_elevation_materialized_view.py new file mode 100644 index 00000000..390ae86f --- /dev/null +++ b/alembic/versions/o8b9c0d1e2f3_rebuild_water_elevation_materialized_view.py @@ -0,0 +1,199 @@ +"""rebuild water elevation materialized view + +Revision ID: o8b9c0d1e2f3 +Revises: n7a8b9c0d1e2 +Create Date: 2026-03-10 15:30:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "o8b9c0d1e2f3" +down_revision: Union[str, Sequence[str], None] = "n7a8b9c0d1e2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +METERS_TO_FEET = 3.28084 + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + + +def _create_water_elevation_view() -> str: + return f""" + CREATE MATERIALIZED VIEW ogc_water_elevation_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + CASE + WHEN lower(trim(o.unit)) IN ('m', 'meter', 'meters', 'metre', 'metres') THEN + (o.value * {METERS_TO_FEET}) - COALESCE(o.measuring_point_height, 0) + WHEN lower(trim(o.unit)) IN ('ft', 'foot', 'feet') THEN + o.value - COALESCE(o.measuring_point_height, 0) + ELSE + NULL + END AS depth_to_water_below_ground_surface + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL + AND lower(trim(o.unit)) IN ( + 'm', + 'meter', + 'meters', + 'metre', + 'metres', + 'ft', + 'foot', + 'feet' + ) + ), + latest_obs AS ( + SELECT + ro.*, + ROW_NUMBER() OVER ( + PARTITION BY ro.thing_id + ORDER BY ro.observation_datetime DESC, ro.observation_id DESC + ) AS rn + FROM ranked_obs AS ro + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + lo.observation_id, + lo.observation_datetime, + l.elevation AS elevation_m, + lo.depth_to_water_below_ground_surface AS depth_to_water_below_ground_surface_ft, + ((l.elevation * {METERS_TO_FEET}) - lo.depth_to_water_below_ground_surface) + AS water_elevation_ft, + l.point + FROM latest_obs AS lo + JOIN thing AS t ON t.id = lo.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lo.rn = 1 + """ + + +def _create_water_elevation_view_pre_feet_fix() -> str: + return f""" + CREATE MATERIALIZED VIEW ogc_water_elevation_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + (o.value - COALESCE(o.measuring_point_height, 0)) + AS depth_to_water_below_ground_surface, + ROW_NUMBER() OVER ( + PARTITION BY fe.thing_id + ORDER BY o.observation_datetime DESC, o.id DESC + ) AS rn + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + ro.observation_id, + ro.observation_datetime, + l.elevation, + ro.depth_to_water_below_ground_surface, + ( + l.elevation - ro.depth_to_water_below_ground_surface + ) AS water_elevation, + l.point + FROM ranked_obs AS ro + JOIN thing AS t ON t.id = ro.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE ro.rn = 1 + """ + + +def _required_tables_present() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + required_tables = { + "thing", + "location", + "location_thing_association", + "observation", + "sample", + "field_activity", + "field_event", + } + + if not required_tables.issubset(existing_tables): + missing = sorted(t for t in required_tables if t not in existing_tables) + raise RuntimeError( + "Cannot create ogc_water_elevation_wells. Missing required tables: " + + ", ".join(missing) + ) + + +def _rebuild(create_sql: str, comment: str) -> None: + op.execute(text("DROP MATERIALIZED VIEW IF EXISTS ogc_water_elevation_wells")) + op.execute(text(create_sql)) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_water_elevation_wells IS " f"'{comment}'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_water_elevation_wells_id " + "ON ogc_water_elevation_wells (id)" + ) + ) + + +def upgrade() -> None: + _required_tables_present() + _rebuild( + _create_water_elevation_view(), + "Latest water elevation per well with explicit units: " + "elevation_m, depth_to_water_below_ground_surface_ft, water_elevation_ft.", + ) + + +def downgrade() -> None: + _rebuild( + _create_water_elevation_view_pre_feet_fix(), + "Latest water elevation per well (elevation minus depth to water below ground surface).", + ) diff --git a/alembic/versions/p9c0d1e2f3a4_add_transducer_observation_deployment_index.py b/alembic/versions/p9c0d1e2f3a4_add_transducer_observation_deployment_index.py new file mode 100644 index 00000000..ea512a86 --- /dev/null +++ b/alembic/versions/p9c0d1e2f3a4_add_transducer_observation_deployment_index.py @@ -0,0 +1,30 @@ +"""Add transducer observation deployment lookup index. + +Revision ID: p9c0d1e2f3a4 +Revises: o8b9c0d1e2f3 +Create Date: 2026-03-19 11:05:00.000000 +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "p9c0d1e2f3a4" +down_revision = "o8b9c0d1e2f3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index( + "ix_transducer_observation_deployment_parameter_datetime", + "transducer_observation", + ["deployment_id", "parameter_id", "observation_datetime"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_transducer_observation_deployment_parameter_datetime", + table_name="transducer_observation", + ) diff --git a/alembic/versions/r2s3t4u5v6w7_add_actively_monitored_wells_pygeoapi_materialized_view.py b/alembic/versions/r2s3t4u5v6w7_add_actively_monitored_wells_pygeoapi_materialized_view.py new file mode 100644 index 00000000..4cbd9612 --- /dev/null +++ b/alembic/versions/r2s3t4u5v6w7_add_actively_monitored_wells_pygeoapi_materialized_view.py @@ -0,0 +1,114 @@ +"""add actively monitored wells pygeoapi view + +Revision ID: r2s3t4u5v6w7 +Revises: p9c0d1e2f3a4 +Create Date: 2026-03-19 10:10:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "r2s3t4u5v6w7" +down_revision: Union[str, Sequence[str], None] = "p9c0d1e2f3a4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None +DROP_VIEW_SQL = "DROP VIEW IF EXISTS ogc_actively_monitored_wells" +DROP_MATVIEW_SQL = "".join( + [ + "DROP MATERIALIZED VIEW IF EXISTS ", + "ogc_actively_monitored_wells", + ] +) + + +def _create_actively_monitored_wells_view() -> str: + return """ + CREATE VIEW ogc_actively_monitored_wells AS + WITH latest_monitoring_status AS ( + SELECT DISTINCT ON (sh.target_id) + sh.target_id AS thing_id, + sh.status_value + FROM status_history AS sh + WHERE + sh.target_table = 'thing' + AND sh.status_type = 'Monitoring Status' + ORDER BY sh.target_id, sh.start_date DESC, sh.id DESC + ) + SELECT + wws.id, + wws.name, + 'water well'::text AS thing_type, + wws.well_depth, + wws.elevation, + wws.elevation_method, + wws.formation_zone, + wws.total_water_levels, + wws.last_water_level, + wws.last_water_level_datetime, + wws.min_water_level, + wws.max_water_level, + wws.water_level_trend_ft_per_year, + g.id AS group_id, + g.name AS group_name, + g.group_type, + wws.point + FROM "group" AS g + JOIN group_thing_association AS gta ON gta.group_id = g.id + JOIN ogc_water_well_summary AS wws ON wws.id = gta.thing_id + JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id + WHERE lower(trim(g.name)) = 'water level network' + AND lms.status_value = 'Currently monitored' + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + required_tables = { + "group", + "group_thing_association", + "status_history", + } + + if not required_tables.issubset(existing_tables): + missing = sorted( + table_name + for table_name in required_tables + if table_name not in existing_tables + ) + raise RuntimeError( + "Cannot create ogc_actively_monitored_wells. " + f"Missing required tables: {', '.join(missing)}" + ) + + has_summary = bind.execute( + text( + "SELECT 1 FROM pg_matviews " + "WHERE schemaname = 'public' " + "AND matviewname = 'ogc_water_well_summary'" + ) + ).scalar() + if has_summary != 1: + raise RuntimeError( + "Cannot create ogc_actively_monitored_wells. " + "Missing required materialized view: ogc_water_well_summary" + ) + + op.execute(text(DROP_VIEW_SQL)) + op.execute(text(DROP_MATVIEW_SQL)) + op.execute(text(_create_actively_monitored_wells_view())) + op.execute( + text( + "COMMENT ON VIEW ogc_actively_monitored_wells IS " + "'Wells in the Water Level Network group for pygeoapi.'" + ) + ) + + +def downgrade() -> None: + op.execute(text(DROP_VIEW_SQL)) + op.execute(text(DROP_MATVIEW_SQL)) diff --git a/alembic/versions/s4t5u6v7w8x9_drop_unused_well_type_ogc_views.py b/alembic/versions/s4t5u6v7w8x9_drop_unused_well_type_ogc_views.py new file mode 100644 index 00000000..8800ed4d --- /dev/null +++ b/alembic/versions/s4t5u6v7w8x9_drop_unused_well_type_ogc_views.py @@ -0,0 +1,99 @@ +"""drop unused well-type OGC views + +Revision ID: s4t5u6v7w8x9 +Revises: r2s3t4u5v6w7 +Create Date: 2026-03-19 14:30:00.000000 +""" + +import re +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision: str = "s4t5u6v7w8x9" +down_revision: Union[str, Sequence[str], None] = "r2s3t4u5v6w7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +REMOVED_THING_COLLECTIONS = [ + ("abandoned_wells", "abandoned well"), + ("artesian_wells", "artesian well"), + ("dry_holes", "dry hole"), + ("dug_wells", "dug well"), + ("exploration_wells", "exploration well"), + ("injection_wells", "injection well"), + ("monitoring_wells", "monitoring well"), + ("observation_wells", "observation well"), + ("piezometers", "piezometer"), + ("production_wells", "production well"), + ("test_wells", "test well"), +] + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + + +def _safe_view_id(view_id: str) -> str: + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", view_id): + raise ValueError(f"Unsafe view id: {view_id!r}") + return view_id + + +def _drop_view_or_materialized_view(view_name: str) -> None: + op.execute(text(f"DROP VIEW IF EXISTS {view_name}")) + op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {view_name}")) + + +def _create_thing_view(view_id: str, thing_type: str) -> str: + safe_view_id = _safe_view_id(view_id) + escaped_thing_type = thing_type.replace("'", "''") + return f""" + CREATE VIEW ogc_{safe_view_id} AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ) + SELECT + t.id, + t.name, + t.first_visit_date, + t.nma_pk_welldata, + t.well_depth, + t.hole_depth, + t.well_casing_diameter, + t.well_casing_depth, + t.well_completion_date, + t.well_driller_name, + t.well_construction_method, + t.well_pump_type, + t.well_pump_depth, + t.formation_completion_code, + t.nma_formation_zone, + t.release_status, + l.elevation, + l.point + FROM thing AS t + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE t.thing_type = '{escaped_thing_type}' + """ + + +def upgrade() -> None: + for view_id, _ in REMOVED_THING_COLLECTIONS: + _drop_view_or_materialized_view(f"ogc_{_safe_view_id(view_id)}") + + +def downgrade() -> None: + for view_id, thing_type in REMOVED_THING_COLLECTIONS: + safe_view_id = _safe_view_id(view_id) + _drop_view_or_materialized_view(f"ogc_{safe_view_id}") + op.execute(text(_create_thing_view(view_id, thing_type))) diff --git a/alembic/versions/t6u7v8w9x0y1_add_project_areas_ogc_view.py b/alembic/versions/t6u7v8w9x0y1_add_project_areas_ogc_view.py new file mode 100644 index 00000000..c03af311 --- /dev/null +++ b/alembic/versions/t6u7v8w9x0y1_add_project_areas_ogc_view.py @@ -0,0 +1,64 @@ +"""add project areas OGC view + +Revision ID: t6u7v8w9x0y1 +Revises: s4t5u6v7w8x9 +Create Date: 2026-03-19 16:45:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "t6u7v8w9x0y1" +down_revision: Union[str, Sequence[str], None] = "s4t5u6v7w8x9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +DROP_VIEW_SQL = "DROP VIEW IF EXISTS ogc_project_areas" + + +def _create_project_areas_view() -> str: + return """ + CREATE VIEW ogc_project_areas AS + SELECT + g.id, + g.name, + g.description, + g.group_type, + g.release_status, + g.project_area + FROM "group" AS g + WHERE g.project_area IS NOT NULL + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + if "group" not in existing_tables: + raise RuntimeError( + "Cannot create ogc_project_areas. Missing required table: group" + ) + + group_columns = {column["name"] for column in inspector.get_columns("group")} + if "project_area" not in group_columns: + raise RuntimeError( + "Cannot create ogc_project_areas. " + "Missing required column: group.project_area" + ) + + op.execute(text(DROP_VIEW_SQL)) + op.execute(text(_create_project_areas_view())) + op.execute( + text( + "COMMENT ON VIEW ogc_project_areas IS " + "'Project areas for groups with polygon boundaries for pygeoapi.'" + ) + ) + + +def downgrade() -> None: + op.execute(text(DROP_VIEW_SQL)) diff --git a/api/asset.py b/api/asset.py index 6e5b8fde..456b5d3a 100644 --- a/api/asset.py +++ b/api/asset.py @@ -14,11 +14,19 @@ # limitations under the License. # =============================================================================== +import logging +import time + from fastapi import APIRouter, Depends, UploadFile, File from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select from sqlalchemy.exc import ProgrammingError -from starlette.status import HTTP_201_CREATED, HTTP_409_CONFLICT, HTTP_204_NO_CONTENT +from starlette.concurrency import run_in_threadpool +from starlette.status import ( + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_409_CONFLICT, +) from api.pagination import CustomPage from core.dependencies import ( @@ -32,17 +40,38 @@ from schemas.asset import AssetResponse, CreateAsset, UpdateAsset from services.audit_helper import audit_add from services.crud_helper import model_patcher, model_deleter -from services.query_helper import simple_get_by_id -from services.gcs_helper import ( - get_storage_bucket, - gcs_upload, - gcs_remove, - check_asset_exists, - add_signed_url, -) +from services.env import get_bool_env from services.exceptions_helper import PydanticStyleException +from services.query_helper import simple_get_by_id router = APIRouter(prefix="/asset", tags=["asset"]) +logger = logging.getLogger(__name__) + + +def is_debug_timing_enabled() -> bool: + return bool(get_bool_env("API_DEBUG_TIMING", False)) + + +def get_storage_bucket(): + from services.gcs_helper import ( + get_storage_bucket as get_gcs_storage_bucket, + ) + + started_at = time.perf_counter() + try: + return get_gcs_storage_bucket() + finally: + if is_debug_timing_enabled(): + logger.info( + "asset storage bucket resolved", + extra={ + "event": "asset_storage_bucket_resolved", + "bucket_resolution_ms": round( + (time.perf_counter() - started_at) * 1000, + 2, + ), + }, + ) def database_error_handler(payload: CreateAsset, error: ProgrammingError) -> None: @@ -53,8 +82,8 @@ def database_error_handler(payload: CreateAsset, error: ProgrammingError) -> Non error_message = error.orig.args[0]["M"] if ( - error_message - == 'null value in column "thing_id" of relation "asset_thing_association" violates not-null constraint' + error_message == 'null value in column "thing_id" of relation ' + '"asset_thing_association" violates not-null constraint' ): """ Developer's notes @@ -70,10 +99,13 @@ def database_error_handler(payload: CreateAsset, error: ProgrammingError) -> Non "input": {"thing_id": payload.thing_id}, } - raise PydanticStyleException(status_code=HTTP_409_CONFLICT, detail=[detail]) + raise PydanticStyleException( + status_code=HTTP_409_CONFLICT, + detail=[detail], + ) -# POST ========================================================================= +# POST ======================================================================= @router.post( "/upload", status_code=HTTP_201_CREATED, @@ -83,7 +115,24 @@ async def upload_asset( bucket=Depends(get_storage_bucket), file: UploadFile = File(...), ) -> dict: - uri, blob_name = gcs_upload(file, bucket) + from services.gcs_helper import gcs_upload + + # GCS client calls are synchronous and can block for large uploads. + request_started_at = time.perf_counter() + uri, blob_name = await run_in_threadpool(gcs_upload, file, bucket) + if is_debug_timing_enabled(): + logger.info( + "asset upload request completed", + extra={ + "event": "asset_upload_request_completed", + "upload_filename": file.filename, + "content_type": file.content_type, + "upload_request_ms": round( + (time.perf_counter() - request_started_at) * 1000, + 2, + ), + }, + ) return { "uri": uri, "storage_path": blob_name, @@ -105,7 +154,13 @@ async def add_asset( # check to see if an asset entry already exists for # this storage path and thing_id - existing_asset = check_asset_exists(session, storage_path, thing_id=thing_id) + from services.gcs_helper import check_asset_exists + + existing_asset = check_asset_exists( + session, + storage_path, + thing_id=thing_id, + ) if existing_asset: # If an asset already exists, return it return existing_asset @@ -131,7 +186,7 @@ async def add_asset( database_error_handler(asset_data, e) -# GET ========================================================================== +# GET ======================================================================== """ Developer's notes @@ -161,6 +216,8 @@ async def list_assets( def transformer(records: list[Asset]): if thing_id is not None: + from services.gcs_helper import add_signed_url + bucket = get_storage_bucket() records = [add_signed_url(ai, bucket) for ai in records] return records @@ -178,13 +235,15 @@ async def get_asset( """ Retrieve an asset by its ID. """ + from services.gcs_helper import add_signed_url + asset = simple_get_by_id(session, Asset, asset_id) - add_signed_url(asset, bucket) + asset = await run_in_threadpool(add_signed_url, asset, bucket) return asset -# PATCH ======================================================================== +# PATCH ====================================================================== @router.patch("/{asset_id}") async def update_asset( asset_id: int, @@ -198,7 +257,7 @@ async def update_asset( return model_patcher(session, Asset, asset_id, asset_data, user=user) -# DELETE ======================================================================= +# DELETE ===================================================================== @router.delete("/{asset_id}", status_code=HTTP_204_NO_CONTENT) @@ -206,7 +265,8 @@ async def delete_asset( asset_id: int, session: session_dependency, user: admin_dependency ): - # TODO: Interesting issue here. we don't have a way of tracking who deleted a record + # TODO: Interesting issue here. We don't have a way of tracking + # who deleted a record. return model_deleter(session, Asset, asset_id) @@ -220,6 +280,8 @@ async def remove_asset( session: session_dependency, bucket=Depends(get_storage_bucket), ): + from services.gcs_helper import gcs_remove + asset = simple_get_by_id(session, Asset, asset_id) gcs_remove(asset.uri, bucket) diff --git a/api/sample.py b/api/sample.py index 22003a12..fdd471cb 100644 --- a/api/sample.py +++ b/api/sample.py @@ -27,11 +27,13 @@ ) from db.sample import Sample from schemas import ResourceNotFoundResponse -from schemas.sample import SampleResponse, CreateSample, UpdateSample -from services.query_helper import simple_get_by_id +from schemas.sample import CreateSample, SampleResponse, UpdateSample from services.crud_helper import model_patcher, model_deleter, model_adder from services.exceptions_helper import PydanticStyleException -from services.sample_helper import get_db_samples +from services.sample_helper import ( + get_db_samples, + get_sample_by_id_with_relationships, +) router = APIRouter( prefix="/sample", @@ -42,44 +44,56 @@ # TODO: add the following database validation handlers # invalid sample_id # invalid lexicon terms -# sample_date of the Sample model cannot be before the event_date of the FieldEvent model +# sample_date of the Sample model cannot be before the +# event_date of the FieldEvent model def database_error_handler( - payload: CreateSample | UpdateSample, error: IntegrityError | ProgrammingError + payload: CreateSample | UpdateSample, + error: IntegrityError | ProgrammingError, ) -> None: """ Handle errors raised by the database when adding or updating a sample. """ error_message = error.orig.args[0]["M"] if ( - error_message - == 'duplicate key value violates unique constraint "sample_sample_name_key"' + error_message == "duplicate key value violates unique " + 'constraint "sample_sample_name_key"' ): detail = { "loc": ["body", "sample_name"], - "msg": f"Sample with sample_name {payload.sample_name} already exists.", + "msg": ( + f"Sample with sample_name {payload.sample_name} " "already exists." + ), "type": "value_error", "input": {"sample_name": payload.sample_name}, } elif ( error_message - == 'insert or update on table "sample" violates foreign key constraint "sample_field_activity_id_fkey"' + == 'insert or update on table "sample" violates foreign key constraint ' + '"sample_field_activity_id_fkey"' ): detail = { "loc": ["body", "field_activity_id"], - "msg": f"FieldActivity with ID {payload.field_activity_id} does not exist.", + "msg": ( + f"FieldActivity with ID {payload.field_activity_id} " "does not exist." + ), "type": "value_error", "input": {"field_activity_id": payload.field_activity_id}, } - raise PydanticStyleException(status_code=HTTP_409_CONFLICT, detail=[detail]) + raise PydanticStyleException( + status_code=HTTP_409_CONFLICT, + detail=[detail], + ) # ============= Post ============================================= @router.post("", status_code=HTTP_201_CREATED) async def add_sample( - sample_data: CreateSample, session: session_dependency, user: admin_dependency + sample_data: CreateSample, + session: session_dependency, + user: admin_dependency, ) -> SampleResponse: """ Endpoint to add a sample. @@ -106,7 +120,13 @@ async def update_sample( try: # since this is only one instance N+1 is not a concern for # FieldActivity, FieldEvent, and Thing - return model_patcher(session, Sample, sample_id, sample_data, user=user) + return model_patcher( + session, + Sample, + sample_id, + sample_data, + user=user, + ) except (IntegrityError, ProgrammingError) as e: database_error_handler(sample_data, e) @@ -124,27 +144,38 @@ async def get_samples( """ Endpoint to retrieve samples. """ - return get_db_samples(session, thing_id, sort=sort, order=order, filter_=filter_) + return get_db_samples( + session, + thing_id, + sort=sort, + order=order, + filter_=filter_, + ) @router.get("/{sample_id}", summary="Get Sample by ID") async def get_sample_by_id( - sample_id: int, session: session_dependency, user: viewer_dependency + sample_id: int, + session: session_dependency, + user: viewer_dependency, ) -> SampleResponse | ResourceNotFoundResponse: """ Endpoint to retrieve a sample by its ID. """ - # since this is only one instance N+1 is not a concern - # FieldActivity, FieldEvent, and Thing - return simple_get_by_id(session, Sample, sample_id) + return get_sample_by_id_with_relationships(session, sample_id) # ======= DELETE =============================================================== -@router.delete("/{sample_id}", summary="Delete Sample by ID") +@router.delete( + "/{sample_id}", + summary="Delete Sample by ID", +) async def delete_sample_by_id( - sample_id: int, session: session_dependency, user: admin_dependency + sample_id: int, + session: session_dependency, + user: admin_dependency, ) -> Response: return model_deleter(session, Sample, sample_id) diff --git a/api/thing.py b/api/thing.py index 367237f5..5b8a52e1 100644 --- a/api/thing.py +++ b/api/thing.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +from typing import Optional from fastapi import APIRouter, Query, Request from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select @@ -51,6 +52,8 @@ UpdateThingIdLink, UpdateWellScreen, ) +from schemas.well_details import WellDetailsResponse +from schemas.well_export import WellExportResponse from services.crud_helper import model_patcher, model_adder, model_deleter from services.exceptions_helper import PydanticStyleException from services.lexicon_helper import get_terms_by_category @@ -68,6 +71,10 @@ modify_well_descriptor_tables, WELL_DESCRIPTOR_MODEL_MAP, ) +from services.well_details_helper import ( + get_well_details_payload, + get_well_export_payload, +) router = APIRouter(prefix="/thing", tags=["thing"]) @@ -147,18 +154,26 @@ async def get_water_wells( user: viewer_dependency, session: session_dependency, request: Request, - sort: str = None, - order: str = None, + sort: Optional[str] = None, + order: Optional[str] = None, filter_: str = Query(alias="filter", default=None), - query: str = None, - name: str = None, + query: Optional[str] = None, + name: Optional[str] = None, + include_contacts: bool = False, ) -> CustomPage[WellResponse]: """ Retrieve all wells from the database. """ thing_type = request.url.path.split("/")[2].replace("-", " ") return get_db_things( - filter_, order, query, session, sort, name=name, thing_type=thing_type + filter_, + order, + query, + session, + sort, + name=name, + thing_type=thing_type, + include_contacts=include_contacts, ) @@ -177,6 +192,49 @@ async def get_well_by_id( return get_thing_of_a_thing_type_by_id(session, request, thing_id) +@router.get( + "/water-well/{thing_id}/details", + summary="Get water well details payload", + status_code=HTTP_200_OK, +) +async def get_well_details( + user: viewer_dependency, + thing_id: int, + session: session_dependency, + request: Request, +) -> WellDetailsResponse: + """ + Retrieve the consolidated payload needed to render the well details page. + Hydrograph series and map layer loading are intentionally handled separately. + """ + return get_well_details_payload( + session=session, + request=request, + thing_id=thing_id, + ) + + +@router.get( + "/water-well/{thing_id}/export", + summary="Get water well export payload", + status_code=HTTP_200_OK, +) +async def get_well_export( + user: viewer_dependency, + thing_id: int, + session: session_dependency, + request: Request, +) -> WellExportResponse: + """ + Retrieve the minimal payload needed for field sheet export generation. + """ + return get_well_export_payload( + session=session, + request=request, + thing_id=thing_id, + ) + + @router.get( "/water-well/{thing_id}/well-screen", summary="Get well screens by water well ID", @@ -299,11 +357,11 @@ async def get_thing_id_links( async def get_things( user: viewer_dependency, session: session_dependency, - # thing_id: int = None, - within: str = None, - query: str = None, - sort: str = None, - order: str = None, + within: Optional[str] = None, + query: Optional[str] = None, + sort: Optional[str] = None, + order: Optional[str] = None, + include_contacts: bool = False, filter_: str = Query( default=None, alias="filter", @@ -320,6 +378,7 @@ async def get_things( session, sort, within=within, + include_contacts=include_contacts, ) diff --git a/cli/README.md b/cli/README.md index 42d557c8..a96f745c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -15,10 +15,17 @@ python -m cli.cli --help ## Common commands +- `python -m cli.cli restore-local-db path/to/dump.sql` +- `python -m cli.cli restore-local-db gs://ocotillo/sql-exports/latest.sql.gz` +- `python -m cli.cli scoped-transfer --pointid SM-0001` - `python -m cli.cli transfer-results` - `python -m cli.cli compare-duplicated-welldata` - `python -m cli.cli alembic-upgrade-and-data` +## Guides + +- Beginner guide for scoped transfers: [scoped-transfer.md](./scoped-transfer.md) + ## Notes - CLI logging is written to `cli/logs/`. diff --git a/cli/cli.py b/cli/cli.py index 09c20185..30c9742f 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -24,8 +24,8 @@ import typer from dotenv import load_dotenv -# CLI should honor local `.env` values, even if shell/container vars already exist. -load_dotenv(override=True) +# CLI should load `.env` defaults without clobbering an explicitly prepared environment. +load_dotenv(override=False) os.environ.setdefault("OCO_LOG_CONTEXT", "cli") cli = typer.Typer(help="Command line interface for managing the application.") @@ -52,9 +52,12 @@ class SmokePopulation(str, Enum): PYGEOAPI_MATERIALIZED_VIEWS = ( "ogc_latest_depth_to_water_wells", + "ogc_water_elevation_wells", "ogc_avg_tds_wells", "ogc_depth_to_water_trend_wells", "ogc_water_well_summary", + "ogc_major_chemistry_results", + "ogc_minor_chemistry_wells", ) @@ -131,6 +134,36 @@ def associate_assets_command( associate_assets(root_directory) +@cli.command("restore-local-db") +def restore_local_db( + source: str = typer.Argument( + ..., + help="Local .sql/.sql.gz path or gs://bucket/path.sql[.gz] URI.", + ), + db_name: str | None = typer.Option( + None, + "--db-name", + help="Override POSTGRES_DB for the restore target.", + ), + theme: ThemeMode = typer.Option( + ThemeMode.auto, "--theme", help="Color theme: auto, light, dark." + ), +): + from cli.db_restore import LocalDbRestoreError, restore_local_db_from_sql + + try: + result = restore_local_db_from_sql(source, db_name=db_name) + except LocalDbRestoreError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from exc + + typer.echo( + "Restored " + f"{result.source} into {result.db_name} " + f"on {result.host}:{result.port} as {result.user}." + ) + + @cli.command("transfer-results") def transfer_results( summary_path: Path = typer.Option( @@ -158,6 +191,133 @@ def transfer_results( typer.echo(f"Transfer comparisons: {len(results.results)}") +@cli.command("scoped-transfer") +def scoped_transfer( + pointid: list[str] = typer.Option( + ..., + "--pointid", + help="Legacy PointID to transfer. Repeat --pointid for multiple values.", + ), + only: list[str] = typer.Option( + None, + "--only", + help="Optional transfer family to include. Repeat for multiple values.", + ), + skip: list[str] = typer.Option( + None, + "--skip", + help="Optional transfer family to skip. Repeat for multiple values.", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Plan the scoped transfer without writing any records.", + ), + output_format: OutputFormat | None = typer.Option( + None, + "--output", + help="Optional output format", + ), + theme: ThemeMode = typer.Option( + ThemeMode.auto, "--theme", help="Color theme: auto, light, dark." + ), +): + from services.scoped_transfer import ( + ScopedTransferError, + ScopedTransferOptions, + format_scoped_transfer_json, + run_scoped_transfer, + ) + + colors = _palette(theme) + normalized_pointids = [ + pid.strip().upper() for pid in pointid if pid and pid.strip() + ] + + if output_format != OutputFormat.json: + # Print a quick status line so a long scoped run does not look stuck. + verb = "Planning" if dry_run else "Starting" + phase = "planning" if dry_run else "execution" + typer.secho( + f"{verb} scoped transfer for PointIDs: {', '.join(normalized_pointids)}", + fg=colors["accent"], + bold=True, + ) + typer.secho( + f"Validating requested scope and preparing {phase}...", + fg=colors["muted"], + ) + + try: + result = run_scoped_transfer( + ScopedTransferOptions( + pointids=pointid, + only=only or [], + skip=skip or [], + dry_run=dry_run, + ) + ) + except ScopedTransferError as exc: + typer.secho(str(exc), fg=colors["issue"], bold=True, err=True) + raise typer.Exit(1) from exc + + if output_format == OutputFormat.json: + typer.echo(format_scoped_transfer_json(result)) + raise typer.Exit(result.exit_code) + + header = "[SCOPED TRANSFER] DRY RUN" if result.dry_run else "[SCOPED TRANSFER]" + header_color = colors["ok"] if result.exit_code == 0 else colors["issue"] + typer.secho(header, fg=header_color, bold=True) + typer.secho("=" * 72, fg=colors["accent"]) + typer.secho( + f"Requested PointIDs: {', '.join(result.pointids)}", + fg=colors["accent"], + ) + typer.secho( + f"Selected families: {', '.join(result.selected_families)}", + fg=colors["accent"], + ) + if result.added_prerequisites: + typer.secho( + f"Auto-added prerequisites: {', '.join(result.added_prerequisites)}", + fg=colors["muted"], + ) + typer.echo() + + typer.secho("FAMILY SUMMARY", fg=colors["accent"], bold=True) + for family_result in result.family_results: + detail_parts = [f"rows={family_result.applicable_source_rows}"] + if family_result.created is not None: + detail_parts.append(f"created={family_result.created}") + if family_result.skipped_existing is not None: + detail_parts.append(f"skipped_existing={family_result.skipped_existing}") + if family_result.added_as_prerequisite: + detail_parts.append("prerequisite") + if family_result.detail: + detail_parts.append(family_result.detail) + typer.secho( + f" {family_result.family:<28} {family_result.status:<10} {' '.join(detail_parts)}", + fg=( + colors["ok"] + if family_result.status in ("completed", "planned") + else colors["muted"] + ), + ) + + if result.validation_errors: + typer.echo() + typer.secho("VALIDATION ERRORS", fg=colors["issue"], bold=True) + for error in result.validation_errors: + typer.secho(f" - {error}", fg=colors["issue"]) + + if result.execution_error: + typer.echo() + typer.secho("EXECUTION ERROR", fg=colors["issue"], bold=True) + typer.secho(result.execution_error, fg=colors["issue"]) + + raise typer.Exit(result.exit_code) + + @cli.command("compare-duplicated-welldata") def compare_duplicated_welldata( pointid: list[str] = typer.Option( @@ -633,9 +793,16 @@ def water_levels_bulk_upload( payload = result.payload if isinstance(result.payload, dict) else {} summary = payload.get("summary", {}) validation_errors = payload.get("validation_errors", []) + rows_with_issues = summary.get("validation_errors_or_warnings", 0) - if result.exit_code == 0: + if result.exit_code == 0 and not rows_with_issues: typer.secho("[WATER LEVEL IMPORT] SUCCESS", fg=colors["ok"], bold=True) + elif result.exit_code == 0: + typer.secho( + "[WATER LEVEL IMPORT] COMPLETED WITH ISSUES", + fg=colors["issue"], + bold=True, + ) else: typer.secho( "[WATER LEVEL IMPORT] COMPLETED WITH ISSUES", @@ -676,7 +843,6 @@ def water_levels_bulk_upload( if summary: processed = summary.get("total_rows_processed", 0) imported = summary.get("total_rows_imported", 0) - rows_with_issues = summary.get("validation_errors_or_warnings", 0) typer.secho("SUMMARY", fg=colors["accent"], bold=True) label_width = 16 value_width = 8 @@ -972,6 +1138,32 @@ def refresh_pygeoapi_materialized_views( typer.echo(f"Refreshed {len(target_views)} materialized view(s).") +@cli.command("import-project-area-boundaries") +def import_project_area_boundaries_command( + layer_url: str = typer.Option( + ( + "https://maps.nmt.edu/server/rest/services/Water/" + "Water_Resources/MapServer/17" + ), + "--layer-url", + help="ArcGIS Feature Layer URL for project area boundaries.", + ), +): + from cli.project_area_import import import_project_area_boundaries + + result = import_project_area_boundaries(layer_url=layer_url) + typer.echo(f"Fetched {result.fetched} feature(s).") + typer.echo(f"Matched {result.matched} group row(s).") + typer.echo(f"Created {result.created} group(s).") + typer.echo(f"Updated {result.updated} group project area(s).") + typer.echo(f"Skipped {result.skipped} unchanged group(s).") + if result.unmatched_locations: + typer.echo( + "Unmatched locations: " + ", ".join(result.unmatched_locations), + err=True, + ) + + if __name__ == "__main__": cli() diff --git a/cli/db_restore.py b/cli/db_restore.py new file mode 100644 index 00000000..bd1200c0 --- /dev/null +++ b/cli/db_restore.py @@ -0,0 +1,244 @@ +import getpass +import gzip +import os +import re +import subprocess +import tempfile +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path + +from db.engine import engine, session_ctx +from db.initialization import recreate_public_schema +from services.gcs_helper import get_storage_bucket + +LOCAL_POSTGRES_HOSTS = {"localhost", "127.0.0.1", "::1", "db"} +ROLE_DEPENDENT_SQL_PATTERNS = ( + re.compile(r"^\s*SET\s+ROLE\b", re.IGNORECASE), + re.compile(r"^\s*SET\s+SESSION\s+AUTHORIZATION\b", re.IGNORECASE), + re.compile(r"^\s*ALTER\s+.*\s+OWNER\s+TO\b", re.IGNORECASE), + re.compile(r"^\s*GRANT\b", re.IGNORECASE), + re.compile(r"^\s*REVOKE\b", re.IGNORECASE), + re.compile(r"^\s*ALTER\s+DEFAULT\s+PRIVILEGES\b", re.IGNORECASE), +) + + +class LocalDbRestoreError(RuntimeError): + """Raised when a local database restore cannot be performed safely.""" + + +@dataclass(frozen=True) +class LocalDbRestoreResult: + sql_file: Path + source: str + host: str + port: str + user: str + db_name: str + + +def _is_gcs_uri(source: str) -> bool: + return source.startswith("gs://") + + +def _parse_gcs_uri(source: str) -> tuple[str, str]: + if not _is_gcs_uri(source): + raise LocalDbRestoreError(f"Expected gs:// URI, got {source!r}.") + + path = source[5:] + bucket_name, _, blob_name = path.partition("/") + if not bucket_name or not blob_name: + raise LocalDbRestoreError( + f"Invalid GCS URI {source!r}; expected gs://bucket/path.sql[.gz]." + ) + return bucket_name, blob_name + + +def _validate_restore_source_name(source_name: str) -> None: + if source_name.endswith(".sql") or source_name.endswith(".sql.gz"): + return + + raise LocalDbRestoreError( + "restore-local-db requires a .sql or .sql.gz source; " f"got {source_name!r}." + ) + + +def _decompress_gzip_file(source_path: Path, target_path: Path) -> None: + try: + with gzip.open(source_path, "rb") as compressed: + with open(target_path, "wb") as expanded: + while chunk := compressed.read(1024 * 1024): + expanded.write(chunk) + except (OSError, gzip.BadGzipFile) as exc: + raise LocalDbRestoreError( + f"Failed to decompress gzip source {source_path!r}: " + "file is not a valid gzip-compressed SQL dump or is corrupted." + ) from exc + + +def _sanitize_sql_dump(source_path: Path, target_path: Path) -> None: + try: + with open(source_path, "r", encoding="utf-8") as infile: + with open(target_path, "w", encoding="utf-8") as outfile: + for line in infile: + if any( + pattern.search(line) for pattern in ROLE_DEPENDENT_SQL_PATTERNS + ): + continue + outfile.write(line) + except UnicodeError as exc: + raise LocalDbRestoreError( + f"Failed to read SQL dump {source_path} as UTF-8. " + "Ensure the dump file is UTF-8 encoded and not truncated." + ) from exc + except OSError as exc: + raise LocalDbRestoreError( + f"I/O error while processing SQL dump {source_path} -> {target_path}: {exc}" + ) from exc + + +@contextmanager +def _stage_restore_source(source: str | Path): + source_text = str(source) + _validate_restore_source_name(source_text) + + with tempfile.TemporaryDirectory(prefix="ocotillo-db-restore-") as temp_dir: + temp_dir_path = Path(temp_dir) + expanded_sql_path = temp_dir_path / "expanded.sql" + staged_sql_path = temp_dir_path / "restore.sql" + + if _is_gcs_uri(source_text): + bucket_name, blob_name = _parse_gcs_uri(source_text) + bucket = get_storage_bucket(bucket=bucket_name) + blob = bucket.blob(blob_name) + downloaded_path = temp_dir_path / Path(blob_name).name + blob.download_to_filename(str(downloaded_path)) + + if source_text.endswith(".sql.gz"): + _decompress_gzip_file(downloaded_path, expanded_sql_path) + else: + expanded_sql_path = downloaded_path + _sanitize_sql_dump(expanded_sql_path, staged_sql_path) + yield staged_sql_path, source_text + return + + source_path = Path(source_text) + if not source_path.exists(): + raise LocalDbRestoreError(f"Restore source not found: {source_path}") + if not source_path.is_file(): + raise LocalDbRestoreError(f"Restore source is not a file: {source_path}") + + if source_text.endswith(".sql.gz"): + _decompress_gzip_file(source_path, expanded_sql_path) + else: + expanded_sql_path = source_path + _sanitize_sql_dump(expanded_sql_path, staged_sql_path) + yield staged_sql_path, str(source_path) + + +def _resolve_restore_target( + db_name: str | None = None, +) -> tuple[str, str, str, str, str]: + driver = (os.environ.get("DB_DRIVER") or "").strip().lower() + if driver == "cloudsql": + raise LocalDbRestoreError( + "restore-local-db only supports local PostgreSQL targets; " + "DB_DRIVER=cloudsql is not allowed." + ) + + host = (os.environ.get("POSTGRES_HOST") or "localhost").strip() + if not host: + host = "localhost" + if host not in LOCAL_POSTGRES_HOSTS: + raise LocalDbRestoreError( + "restore-local-db only supports local PostgreSQL hosts " + f"({', '.join(sorted(LOCAL_POSTGRES_HOSTS))}); got {host!r}." + ) + + port = (os.environ.get("POSTGRES_PORT") or "5432").strip() + if not port: + port = "5432" + + user = (os.environ.get("POSTGRES_USER") or "").strip() + if not user: + user = getpass.getuser() + + target_db = (db_name or os.environ.get("POSTGRES_DB") or "postgres").strip() + if not target_db: + raise LocalDbRestoreError("Target database name is empty.") + + password = os.environ.get("POSTGRES_PASSWORD", "") + return host, port, user, target_db, password + + +def _reset_target_schema() -> None: + try: + engine.dispose() + with session_ctx() as session: + recreate_public_schema(session) + engine.dispose() + except Exception as exc: + raise LocalDbRestoreError( + f"Failed to reset the public schema before restore: {exc}" + ) from exc + + +def restore_local_db_from_sql( + source_file: Path | str, *, db_name: str | None = None +) -> LocalDbRestoreResult: + host, port, user, target_db, password = _resolve_restore_target(db_name) + with _stage_restore_source(source_file) as (staged_sql_file, source_description): + try: + _reset_target_schema() + except LocalDbRestoreError: + raise + except Exception as exc: + raise LocalDbRestoreError( + f"Failed to reset the public schema before restore: {exc}" + ) from exc + command = [ + "psql", + "-v", + "ON_ERROR_STOP=1", + "-h", + host, + "-p", + port, + "-U", + user, + "-d", + target_db, + "-f", + str(staged_sql_file), + ] + + env = os.environ.copy() + if password: + env["PGPASSWORD"] = password + + try: + subprocess.run( + command, + check=True, + env=env, + capture_output=True, + text=True, + ) + except FileNotFoundError as exc: + raise LocalDbRestoreError( + "psql is not installed or not available on PATH." + ) from exc + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or exc.stdout or "").strip() or str(exc) + raise LocalDbRestoreError( + f"Restore failed for database {target_db!r}: {detail}" + ) from exc + + return LocalDbRestoreResult( + sql_file=staged_sql_file, + source=source_description, + host=host, + port=port, + user=user, + db_name=target_db, + ) diff --git a/cli/project_area_import.py b/cli/project_area_import.py new file mode 100644 index 00000000..a16d4147 --- /dev/null +++ b/cli/project_area_import.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx +from geoalchemy2 import WKTElement +from shapely.geometry import MultiPolygon, Polygon, shape +from sqlalchemy import func, select + +from db import Group +from db.engine import session_ctx + +PROJECT_AREA_LAYER_URL = "".join( + [ + "https://maps.nmt.edu/server/rest/services/Water/", + "Water_Resources/MapServer/17", + ] +) +PROJECT_AREA_PAGE_SIZE = 1000 + + +@dataclass(frozen=True) +class ProjectAreaImportResult: + fetched: int + matched: int + updated: int + created: int + skipped: int + unmatched_locations: tuple[str, ...] + + +def _normalize_name(value: str) -> str: + return value.strip().lower() + + +def _geoms_equal(geom1: str, geom2: str) -> bool: + from shapely import wkt + + return wkt.loads(geom1).equals(wkt.loads(geom2)) + + +def _geojson_to_multipolygon_wkt(geometry: dict[str, Any]) -> str: + geom = shape(geometry) + if isinstance(geom, Polygon): + geom = MultiPolygon([geom]) + if not isinstance(geom, MultiPolygon): + raise ValueError( + f"Expected Polygon or MultiPolygon geometry, got {geom.geom_type}" + ) + return geom.wkt + + +def _fetch_project_area_features( + client: httpx.Client, + layer_url: str, +) -> list[dict[str, Any]]: + features: list[dict[str, Any]] = [] + offset = 0 + + while True: + response = client.get( + f"{layer_url}/query", + params={ + "where": "1=1", + "outFields": "location", + "returnGeometry": "true", + "f": "geojson", + "resultOffset": offset, + "resultRecordCount": PROJECT_AREA_PAGE_SIZE, + }, + ) + response.raise_for_status() + payload = response.json() + batch = payload.get("features", []) + if not batch: + break + features.extend(batch) + if not payload.get("exceededTransferLimit"): + break + offset += len(batch) + + return features + + +def import_project_area_boundaries( + layer_url: str = PROJECT_AREA_LAYER_URL, + group_type: str = "Geographic Area", +) -> ProjectAreaImportResult: + with httpx.Client(timeout=60.0) as client: + features = _fetch_project_area_features(client, layer_url) + + unmatched_locations: list[str] = [] + matched = 0 + updated = 0 + created = 0 + skipped = 0 + + with session_ctx() as session: + for feature in features: + attributes = feature.get("properties", {}) + geometry = feature.get("geometry") + location_name = (attributes.get("location") or "").strip() + + if not location_name or geometry is None: + continue + + normalized_name = _normalize_name(location_name) + groups = session.scalars( + select(Group).where( + func.lower(func.trim(Group.name)) == normalized_name, + Group.group_type == group_type, + ) + ).all() + + project_area = WKTElement( + _geojson_to_multipolygon_wkt(geometry), + srid=4326, + ) + + if not groups: + unmatched_locations.append(location_name) + new_group = Group( + name=location_name, + group_type=group_type, + project_area=project_area, + ) + session.add(new_group) + created += 1 + matched += 1 + continue + + matched += len(groups) + for group in groups: + old_wkt = None + if group.project_area is not None: + from shapely import wkb + + old_wkt = wkb.loads(bytes(group.project_area.data)).wkt + + new_wkt = project_area.desc + + if old_wkt is None or not _geoms_equal(old_wkt, new_wkt): + group.project_area = project_area + updated += 1 + else: + skipped += 1 + + session.commit() + + return ProjectAreaImportResult( + fetched=len(features), + matched=matched, + updated=updated, + created=created, + skipped=skipped, + unmatched_locations=tuple(sorted(set(unmatched_locations))), + ) diff --git a/cli/scoped-transfer.md b/cli/scoped-transfer.md new file mode 100644 index 00000000..a738a93b --- /dev/null +++ b/cli/scoped-transfer.md @@ -0,0 +1,346 @@ +# Scoped Transfer Guide + +This guide explains how to use `oco scoped-transfer` to run a targeted legacy data transfer for one or more `PointID` values. + +It is written for a beginner who may not use CLI tools often. + +## What `scoped-transfer` does + +`oco scoped-transfer` imports only the records related to the `PointID` values you request. + +This is useful when you want to: + +- test a single well or site +- rerun a small transfer after fixing an issue +- avoid running a full legacy transfer +- inspect what would be imported before writing data + +## Before you start + +Run commands from the project root. + +Activate the virtual environment: + +```bash +source .venv/bin/activate +``` + +Load environment variables from `.env`: + +```bash +set -a +source .env +set +a +``` + +If you skip these steps, the CLI may fail because it cannot find the right Python packages or database settings. + +If `oco` is not available in your shell, you can run the same command with: + +```bash +python -m cli.cli scoped-transfer --pointid SM-0001 +``` + +The examples below use `oco`, but both forms are valid. + +## Basic command + +Transfer one `PointID`: + +```bash +oco scoped-transfer --pointid SM-0001 +``` + +Transfer more than one `PointID`: + +```bash +oco scoped-transfer --pointid SM-0001 --pointid SM-0002 +``` + +The command will: + +1. validate your requested `PointID` values +2. determine which transfer families need to run +3. run the scoped transfer +4. print a final summary + +## What you will see + +At the start of the run, the CLI prints a short status message so you know it is working: + +```text +Starting scoped transfer for PointIDs: SM-0001 +Validating requested scope and preparing execution... +``` + +At the end, it prints a scoped transfer summary like this: + +```text +[SCOPED TRANSFER] +======================================================================== +Requested PointIDs: SM-0001 +Selected families: wells, contacts, permissions, waterlevels, ... + +FAMILY SUMMARY + wells completed rows=1 + contacts completed rows=1 + permissions completed rows=1 created=2 skipped_existing=0 + waterlevels completed rows=38 +``` + +## Understanding the summary + +Each line in `FAMILY SUMMARY` is a transfer family. + +Common statuses: + +- `completed`: the family ran and found matching data +- `planned`: shown during `--dry-run`; the family would run +- `no-op`: the family had no matching data for your requested `PointID` + +Common fields: + +- `rows=...`: number of matching source rows for that family +- `created=...`: number of records created or updated by that step +- `skipped_existing=...`: records skipped because they already existed + +`no-op` is normal. It does not mean the run failed. + +## What are "families"? + +In `scoped-transfer`, a **family** is a group of related records that are imported together. + +You will see family names: + +- in the `Selected families` line +- in the `FAMILY SUMMARY` output +- when using `--only` +- when using `--skip` + +Think of a family as a transfer step for one kind of data. + +For example: + +- `wells` imports the main well/site record +- `contacts` imports owner or related contact records +- `waterlevels` imports manual water-level measurements + +Not every `PointID` has data in every family. That is why many families may show `no-op` in the summary. + +### Family list + +| Family | What it means | +|---|---| +| `wells` | Main water well records and core well details. | +| `springs` | Spring site records. | +| `perennial-streams` | Perennial stream site records. | +| `ephemeral-streams` | Ephemeral stream site records. | +| `met-stations` | Meteorological station site records. | +| `rock-sample-locations` | Rock sample site records. | +| `diversion-of-surface-water` | Surface-water diversion site records. | +| `lake-pond-reservoir` | Lake, pond, or reservoir site records. | +| `soil-gas-sample-locations` | Soil gas sample site records. | +| `other-site-types` | Other site records that do not fit the main site groups. | +| `outfall-wastewater-return-flow` | Outfall or wastewater return flow site records. | +| `screens` | Well screen records linked to wells. | +| `contacts` | Owner or related contact records linked to a site. | +| `permissions` | Permission history such as monitoring or sampling permission. | +| `waterlevels` | Manual groundwater level measurements. | +| `link-ids` | Alternate IDs linked to a site, such as OSE or PLSS-style identifiers. | +| `groups` | Project or grouping records that associate sites together. | +| `assets` | Site images or files, such as photos. | +| `associated-data` | Additional attached data records related to a site. | +| `hydraulics-data` | Hydraulics test or aquifer property data linked to a well. | +| `chemistry-sampleinfo` | Chemistry sample header records for water-quality sampling. | +| `field-parameters` | Field-measured chemistry values linked to a chemistry sample. | +| `major-chemistry` | Major ion chemistry results linked to a chemistry sample. | +| `radionuclides` | Radionuclide chemistry results linked to a chemistry sample. | +| `minor-trace-chemistry` | Minor and trace chemistry results linked to a chemistry sample. | +| `sensors` | Sensor and deployment records for monitoring equipment. | +| `pressure` | Continuous pressure-based water-level records. | +| `acoustic` | Continuous acoustic water-level records. | +| `pressure-daily` | Daily summarized pressure-based water-level records. | +| `ngwmn-views` | NGWMN legacy view records related to well construction and water levels. | +| `nma-stratigraphy` | Legacy stratigraphy records. | +| `surface-water-data` | Surface-water measurement records. | +| `surface-water-photos` | Surface-water photo assets. | +| `weather-data` | Weather measurement records. | +| `weather-photos` | Weather photo assets. | +| `soil-rock-results` | Soil or rock analysis result records. | +| `cleanup-locations` | A cleanup step that fills in location fields such as state, county, or quad name after transfer. | + +## Dry run mode + +Use `--dry-run` to see what would run without writing to the database. + +Example: + +```bash +oco scoped-transfer --pointid SM-0001 --dry-run +``` + +This is the safest way to check your scope before making changes. + +## Limiting the run to specific families + +Use `--only` to run just a few transfer families. + +Example: run only wells + +```bash +oco scoped-transfer --pointid SM-0001 --only wells +``` + +Example: run only water levels + +```bash +oco scoped-transfer --pointid SM-0001 --only waterlevels +``` + +Example: run only chemistry sample info + +```bash +oco scoped-transfer --pointid SM-0001 --only chemistry-sampleinfo +``` + +Important: + +- some families depend on others +- the CLI may automatically add prerequisite families + +For example, if you request `field-parameters`, the CLI may also add `wells` and `chemistry-sampleinfo`. + +You will see that in the final output as: + +```text +Auto-added prerequisites: chemistry-sampleinfo, wells +``` + +## Skipping families + +Use `--skip` to leave out families you do not want to run. + +Example: + +```bash +oco scoped-transfer --pointid SM-0001 --skip assets --skip weather-photos +``` + +This is useful when: + +- you are narrowing a test run +- a family is known to be irrelevant for your target +- you want faster iteration while debugging + +## JSON output + +Use `--output json` if you want machine-readable output. + +Example: + +```bash +oco scoped-transfer --pointid SM-0001 --dry-run --output json +``` + +This is useful for scripting or saving results to another tool. + +When JSON output is enabled, the CLI prints JSON instead of the human summary. + +## Common examples + +### Example 1: Preview a transfer for one well + +```bash +oco scoped-transfer --pointid SM-0001 --dry-run +``` + +Use this first when you are not sure what data exists. + +### Example 2: Run the full scoped transfer for one well + +```bash +oco scoped-transfer --pointid SM-0001 +``` + +Use this after the dry run looks correct. + +### Example 3: Re-run only water levels for one well + +```bash +oco scoped-transfer --pointid SM-0001 --only waterlevels +``` + +Use this when you are debugging water-level behavior. + +### Example 4: Run wells and contacts only + +```bash +oco scoped-transfer --pointid SM-0001 --only wells --only contacts +``` + +Use this when you want a smaller targeted import. + +### Example 5: Run two PointIDs together + +```bash +oco scoped-transfer --pointid SM-0001 --pointid SM-0002 +``` + +Use this when the same test or fix should be checked for more than one site. + +## Troubleshooting + +### The command says a `PointID` was not found + +That usually means the requested `PointID` does not appear in the source data for the selected scope. + +Try: + +- checking for typos +- confirming letter case and punctuation +- running a dry run again + +### A family shows `no-op` + +That means the family had no matching rows for the requested `PointID`. + +This is expected for many families. Not every site has data in every table. + +### The command finishes but creates less than expected + +Check: + +- whether you used `--only` or `--skip` +- whether prerequisites were auto-added +- the `rows=...` counts in the summary +- whether data may already exist and be counted as `skipped_existing` + + +## Related files + +Main CLI command: + +- `cli/cli.py` + +Scoped transfer service: + +- `services/scoped_transfer.py` + +## Quick reference + +```bash +# Basic run +oco scoped-transfer --pointid SM-0001 + +# Dry run +oco scoped-transfer --pointid SM-0001 --dry-run + +# Only one family +oco scoped-transfer --pointid SM-0001 --only waterlevels + +# Skip one family +oco scoped-transfer --pointid SM-0001 --skip assets + +# JSON output +oco scoped-transfer --pointid SM-0001 --output json +``` diff --git a/cli/service_adapter.py b/cli/service_adapter.py index 3e7eb770..bc7bb6cc 100644 --- a/cli/service_adapter.py +++ b/cli/service_adapter.py @@ -50,19 +50,33 @@ def well_inventory_csv(source_file: Path | str): payload = {"detail": "Empty file"} return WellInventoryResult(1, json.dumps(payload), payload["detail"], payload) try: - text = content.decode("utf-8") + # Accept UTF-8 CSVs saved with a BOM so the first header is parsed correctly. + text = content.decode("utf-8-sig") except UnicodeDecodeError: payload = {"detail": "File encoding error"} return WellInventoryResult(1, json.dumps(payload), payload["detail"], payload) try: + progress_callback = None + if sys.stdout.isatty(): + progress_callback = lambda message: print(message, flush=True) payload = import_well_inventory_csv( - text=text, user={"sub": "cli", "name": "cli"} + text=text, + user={"sub": "cli", "name": "cli"}, + progress_callback=progress_callback, ) except ValueError as exc: payload = {"detail": str(exc)} return WellInventoryResult(1, json.dumps(payload), payload["detail"], payload) - exit_code = 0 if not payload.get("validation_errors") else 1 - return WellInventoryResult(exit_code, json.dumps(payload), "", payload) + exit_code = ( + 0 if not payload.get("validation_errors") and not payload.get("detail") else 1 + ) + stderr = "" + if exit_code != 0: + if payload.get("validation_errors"): + stderr = f"Validation errors: {json.dumps(payload.get('validation_errors'), indent=2)}" + else: + stderr = f"Error: {payload.get('detail')}" + return WellInventoryResult(exit_code, json.dumps(payload), stderr, payload) def water_levels_csv(source_file: Path | str, *, pretty_json: bool = False): diff --git a/core/app.py b/core/app.py index 978419f6..102256d4 100644 --- a/core/app.py +++ b/core/app.py @@ -13,11 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +import logging import os +import time +from uuid import uuid4 from contextlib import asynccontextmanager from typing import AsyncGenerator from fastapi import FastAPI +from fastapi import Request from fastapi.openapi.docs import ( get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html, @@ -26,6 +30,8 @@ from .settings import settings +logger = logging.getLogger(__name__) + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: @@ -37,147 +43,182 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: seed_all(10, skip_if_exists=True) - yield - + app.state.instance_ready_at = time.perf_counter() + logger.info( + "instance startup complete", + extra={ + "event": "instance_startup_complete", + "startup_ms": round( + (app.state.instance_ready_at - app.state.process_boot_started_at) + * 1000, + 2, + ), + }, + ) -app = FastAPI( - title="Sample Location API", - description="API for managing sample locations", - version=settings.version, - lifespan=lifespan, -) + yield -# --- full OpenAPI schema --- -def full_openapi(): - if app.openapi_schema: - return app.openapi_schema - schema = get_openapi( - title="Ocotillo API (Full)", +def create_base_app() -> FastAPI: + app = FastAPI( + title="Sample Location API", + description="API for managing sample locations", version=settings.version, - description="Full API schema (authorized users)", - routes=app.routes, + lifespan=lifespan, ) - app.openapi_schema = schema - return app.openapi_schema - - -# --- public OpenAPI schema --- -def public_openapi(): - schema = get_openapi( - title="Ocotillo API (Public)", - version="0.0.1", - description="Public API schema (anonymous users)", - routes=app.routes, - ) - - # Keep only operations where the endpoint function is marked public - new_paths = {} - for path, path_item in schema["paths"].items(): - new_methods = {} - for method, operation in path_item.items(): - # Recover the actual route handler - - route = next( - ( - r - for r in app.routes - if r.path == path and method.upper() in r.methods - ), - None, + app.state.process_boot_started_at = time.perf_counter() + app.state.instance_ready_at = None + + @app.middleware("http") + async def log_request_lifecycle(request: Request, call_next): + request_id = uuid4().hex + request.state.request_id = request_id + logger.info( + "request started", + extra={ + "event": "request_started", + "request_id": request_id, + "method": request.method, + "path": request.url.path, + }, + ) + status_code = 500 + try: + response = await call_next(request) + status_code = response.status_code + return response + finally: + logger.info( + "request completed", + extra={ + "event": "request_completed", + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "status_code": status_code, + }, ) - if not route: - continue - - endpoint = getattr(route, "endpoint", None) - if getattr(endpoint, "_is_public", False): - # Strip security info for public docs - operation["security"] = [] - new_methods[method] = operation - - if new_methods: - new_paths[path] = new_methods - - schema["paths"] = new_paths - - # --- Collect all referenced schemas recursively --- - referenced = set() - - def collect_refs(obj): - if isinstance(obj, dict): - for k, v in obj.items(): - if ( - k == "$ref" - and isinstance(v, str) - and v.startswith("#/components/schemas/") - ): - referenced.add(v.split("/")[-1]) - else: - collect_refs(v) - elif isinstance(obj, list): - for item in obj: - collect_refs(item) - - # Step 1: Collect refs from paths - collect_refs(schema["paths"]) - - # Step 2: Recursively resolve inside components - visited = set() - to_visit = set(referenced) - - while to_visit: - name = to_visit.pop() - if name in visited: - continue - visited.add(name) - - model = schema.get("components", {}).get("schemas", {}).get(name) - if not model: - continue - - collect_refs(model) - # Add only new schemas we haven’t visited yet - to_visit |= referenced - visited - - # Step 3: Filter components.schemas to only referenced ones - if "components" in schema and "schemas" in schema["components"]: - schema["components"]["schemas"] = { - n: m for n, m in schema["components"]["schemas"].items() if n in referenced - } - - # 4. Drop security schemes entirely for the public spec - if "components" in schema and "securitySchemes" in schema["components"]: - schema["components"].pop("securitySchemes", None) - return schema - - -# set the public schema as the default -app.openapi = public_openapi - -CLIENT_ID = os.environ.get("AUTHENTIK_CLIENT_ID") - - -@app.get("/docs-auth", include_in_schema=False) -async def custom_swagger_ui(): - return get_swagger_ui_html( - openapi_url="/openapi-auth.json", - title="Swagger UI", - oauth2_redirect_url="/docs-auth/oauth2-redirect", - init_oauth={ - "clientId": CLIENT_ID, - "usePkceWithAuthorizationCodeGrant": True, # if you use PKCE - }, - ) + def full_openapi(): + if app.openapi_schema: + return app.openapi_schema + schema = get_openapi( + title="Ocotillo API (Full)", + version=settings.version, + description="Full API schema (authorized users)", + routes=app.routes, + ) + app.openapi_schema = schema + return app.openapi_schema -@app.get("/openapi-auth.json", include_in_schema=False) -async def get_openapi_auth(): - return full_openapi() + def public_openapi(): + schema = get_openapi( + title="Ocotillo API (Public)", + version="0.0.1", + description="Public API schema (anonymous users)", + routes=app.routes, + ) + + # Keep only operations where the endpoint function is marked public. + new_paths = {} + for path, path_item in schema["paths"].items(): + new_methods = {} + for method, operation in path_item.items(): + route = next( + ( + r + for r in app.routes + if r.path == path and method.upper() in r.methods + ), + None, + ) + if not route: + continue + + endpoint = getattr(route, "endpoint", None) + if getattr(endpoint, "_is_public", False): + operation["security"] = [] + new_methods[method] = operation + + if new_methods: + new_paths[path] = new_methods + + schema["paths"] = new_paths + + referenced = set() + + def collect_refs(obj): + if isinstance(obj, dict): + for key, value in obj.items(): + if ( + key == "$ref" + and isinstance(value, str) + and value.startswith("#/components/schemas/") + ): + referenced.add(value.split("/")[-1]) + else: + collect_refs(value) + elif isinstance(obj, list): + for item in obj: + collect_refs(item) + + collect_refs(schema["paths"]) + + visited = set() + to_visit = set(referenced) + while to_visit: + name = to_visit.pop() + if name in visited: + continue + visited.add(name) + model = schema.get("components", {}).get("schemas", {}).get(name) + if not model: + continue -@app.get("/docs-auth/oauth2-redirect", include_in_schema=False) -async def swagger_ui_redirect(): - return get_swagger_ui_oauth2_redirect_html() + collect_refs(model) + to_visit |= referenced - visited + + if "components" in schema and "schemas" in schema["components"]: + schema["components"]["schemas"] = { + name: model + for name, model in schema["components"]["schemas"].items() + if name in referenced + } + + if "components" in schema and "securitySchemes" in schema["components"]: + schema["components"].pop("securitySchemes", None) + return schema + + app.openapi = public_openapi + + client_id = os.environ.get("AUTHENTIK_CLIENT_ID") + + @app.get("/docs-auth", include_in_schema=False) + async def custom_swagger_ui(): + return get_swagger_ui_html( + openapi_url="/openapi-auth.json", + title="Swagger UI", + oauth2_redirect_url="/docs-auth/oauth2-redirect", + init_oauth={ + "clientId": client_id, + "usePkceWithAuthorizationCodeGrant": True, + }, + ) + + @app.get("/openapi-auth.json", include_in_schema=False) + async def get_openapi_auth(): + return full_openapi() + + @app.get("/docs-auth/oauth2-redirect", include_in_schema=False) + async def swagger_ui_redirect(): + return get_swagger_ui_oauth2_redirect_html() + + @app.get("/_ah/warmup", include_in_schema=False) + async def warmup(): + return {"status": "ok"} + + return app def public_route(func): diff --git a/core/enums.py b/core/enums.py index 43c16c2d..663f367e 100644 --- a/core/enums.py +++ b/core/enums.py @@ -48,7 +48,7 @@ ) LimitType: type[Enum] = build_enum_from_lexicon_category("limit_type") MeasurementMethod: type[Enum] = build_enum_from_lexicon_category("measurement_method") -MonitoringStatus: type[Enum] = build_enum_from_lexicon_category("monitoring_status") +MonitoringStatus: type[Enum] = build_enum_from_lexicon_category("status_value") ParameterName: type[Enum] = build_enum_from_lexicon_category("parameter_name") Organization: type[Enum] = build_enum_from_lexicon_category("organization") OriginType: type[Enum] = build_enum_from_lexicon_category("origin_type") diff --git a/core/factory.py b/core/factory.py new file mode 100644 index 00000000..69bcfba7 --- /dev/null +++ b/core/factory.py @@ -0,0 +1,55 @@ +import os + +from dotenv import load_dotenv + +from core.app import create_base_app +from core.initializers import ( + configure_apitally_middleware, + configure_cors_middleware, + configure_lazy_admin, + configure_session_middleware, + register_api_routes, +) + +_runtime_initialized = False + + +def initialize_runtime() -> None: + global _runtime_initialized + + if _runtime_initialized: + return + + load_dotenv(override=False) + dsn = os.environ.get("SENTRY_DSN") + if dsn: + import sentry_sdk + + sentry_sdk.init( + dsn=dsn, + traces_sample_rate=float( + os.environ.get("SENTRY_TRACES_SAMPLE_RATE", "0.1") + ), + profiles_sample_rate=float( + os.environ.get("SENTRY_PROFILES_SAMPLE_RATE", "0.0") + ), + profile_lifecycle="trace", + send_default_pii=True, + ) + + _runtime_initialized = True + + +def create_api_app(): + initialize_runtime() + app = create_base_app() + register_api_routes(app) + from core.pygeoapi import mount_pygeoapi + + mount_pygeoapi(app) + if os.environ.get("SESSION_SECRET_KEY"): + configure_session_middleware(app) + configure_cors_middleware(app) + configure_apitally_middleware(app) + configure_lazy_admin(app) + return app diff --git a/core/initializers.py b/core/initializers.py index ba932b9b..98da4e8e 100644 --- a/core/initializers.py +++ b/core/initializers.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +import asyncio import os from pathlib import Path @@ -20,6 +21,7 @@ from sqlalchemy import text, select from sqlalchemy.dialects.postgresql import insert from sqlalchemy.exc import DatabaseError +from starlette.responses import PlainTextResponse from db import Base from db.engine import session_ctx @@ -193,11 +195,10 @@ def init_lexicon(path: str = None) -> None: session.commit() -def register_routes(app): - if getattr(app.state, "routes_registered", False): +def register_api_routes(app): + if getattr(app.state, "api_routes_registered", False): return - from admin.auth_routes import router as admin_auth_router from api.group import router as group_router from api.contact import router as contact_router from api.location import router as location_router @@ -215,15 +216,12 @@ def register_routes(app): from api.search import router as search_router from api.geospatial import router as geospatial_router from api.ngwmn import router as ngwmn_router - from core.pygeoapi import mount_pygeoapi app.include_router(asset_router) - app.include_router(admin_auth_router) app.include_router(author_router) app.include_router(contact_router) app.include_router(geospatial_router) app.include_router(group_router) - mount_pygeoapi(app) app.include_router(lexicon_router) app.include_router(location_router) app.include_router(observation_router) @@ -234,11 +232,10 @@ def register_routes(app): app.include_router(thing_router) app.include_router(ngwmn_router) add_pagination(app) - app.state.routes_registered = True + app.state.api_routes_registered = True -def configure_middleware(app): - from starlette.middleware.cors import CORSMiddleware +def configure_session_middleware(app): from starlette.middleware.sessions import SessionMiddleware if not getattr(app.state, "session_middleware_configured", False): @@ -248,6 +245,10 @@ def configure_middleware(app): app.add_middleware(SessionMiddleware, secret_key=session_secret_key) app.state.session_middleware_configured = True + +def configure_cors_middleware(app): + from starlette.middleware.cors import CORSMiddleware + if not getattr(app.state, "cors_middleware_configured", False): app.add_middleware( CORSMiddleware, @@ -258,6 +259,8 @@ def configure_middleware(app): ) app.state.cors_middleware_configured = True + +def configure_apitally_middleware(app): apitally_client_id = os.environ.get("APITALLY_CLIENT_ID") if apitally_client_id and not getattr( app.state, "apitally_middleware_configured", False @@ -278,14 +281,44 @@ def configure_middleware(app): app.state.apitally_middleware_configured = True +def configure_middleware(app): + configure_session_middleware(app) + configure_cors_middleware(app) + configure_apitally_middleware(app) + + def configure_admin(app): if getattr(app.state, "admin_configured", False): return from admin import create_admin + from admin.auth_routes import router as admin_auth_router + app.include_router(admin_auth_router) create_admin(app) app.state.admin_configured = True +def configure_lazy_admin(app): + if getattr(app.state, "lazy_admin_configured", False): + return + + app.state.admin_configure_lock = asyncio.Lock() + + @app.middleware("http") + async def ensure_admin_initialized(request, call_next): + if request.url.path.startswith("/admin"): + if not getattr(app.state, "session_middleware_configured", False): + return PlainTextResponse( + "Admin requires SESSION_SECRET_KEY to be configured.", + status_code=503, + ) + async with app.state.admin_configure_lock: + if not getattr(app.state, "admin_configured", False): + configure_admin(app) + return await call_next(request) + + app.state.lazy_admin_configured = True + + # ============= EOF ============================================= diff --git a/core/lexicon.json b/core/lexicon.json index 32757116..82942c48 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -4452,6 +4452,111 @@ "term": "Zamora Accounting Services", "definition": "Zamora Accounting Services" }, + { + "categories": [ + "organization" + ], + "term": "Agua Sana MWCD", + "definition": "Agua Sana MWCD" + }, + { + "categories": [ + "organization" + ], + "term": "Canada Los Alamos MDWCA", + "definition": "Canada Los Alamos MDWCA" + }, + { + "categories": [ + "organization" + ], + "term": "Canjilon Mutual Domestic Water System", + "definition": "Canjilon Mutual Domestic Water System" + }, + { + "categories": [ + "organization" + ], + "term": "Cebolla Mutual Domestic", + "definition": "Cebolla Mutual Domestic" + }, + { + "categories": [ + "organization" + ], + "term": "Chihuahuan Desert Rangeland Research Center (CDRRC)", + "definition": "Chihuahuan Desert Rangeland Research Center (CDRRC)" + }, + { + "categories": [ + "organization" + ], + "term": "East Rio Arriba SWCD", + "definition": "East Rio Arriba SWCD" + }, + { + "categories": [ + "organization" + ], + "term": "El Prado Municipal Water", + "definition": "El Prado Municipal Water" + }, + { + "categories": [ + "organization" + ], + "term": "Hachita Mutual Domestic", + "definition": "Hachita Mutual Domestic" + }, + { + "categories": [ + "organization" + ], + "term": "Jornada Experimental Range (JER)", + "definition": "Jornada Experimental Range (JER)" + }, + { + "categories": [ + "organization" + ], + "term": "La Canada Way HOA", + "definition": "La Canada Way HOA" + }, + { + "categories": [ + "organization" + ], + "term": "Los Ojos Mutual Domestic", + "definition": "Los Ojos Mutual Domestic" + }, + { + "categories": [ + "organization" + ], + "term": "The Nature Conservancy (TNC)", + "definition": "The Nature Conservancy (TNC)" + }, + { + "categories": [ + "organization" + ], + "term": "Smith Ranch LLC", + "definition": "Smith Ranch LLC" + }, + { + "categories": [ + "organization" + ], + "term": "Zia Pueblo", + "definition": "Zia Pueblo" + }, + { + "categories": [ + "organization" + ], + "term": "Our Lady of Guadalupe (OLG)", + "definition": "Our Lady of Guadalupe (OLG)" + }, { "categories": [ "organization" @@ -8185,6 +8290,13 @@ "term": "Water", "definition": "Water bearing zone information and other info from ose reports" }, + { + "categories": [ + "note_type" + ], + "term": "Water Quality", + "definition": "Water quality information" + }, { "categories": [ "note_type" diff --git a/core/permissions.py b/core/permissions.py index b5ce731a..952e844f 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -13,8 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -# import os import os +from functools import lru_cache from typing import Optional, List, Union, cast, Callable import httpx @@ -36,18 +36,20 @@ if AUTHENTIK_ISSUER and not auth_disabled: JWKS_URL = f"{AUTHENTIK_ISSUER}jwks/" - # Fetch JWKS (could also cache this) - def get_jwks(): - resp = httpx.get(JWKS_URL) - resp.raise_for_status() - return resp.json() - jwks = get_jwks() +@lru_cache(maxsize=1) +def get_jwks(): + if not AUTHENTIK_ISSUER or auth_disabled: + return {} + + resp = httpx.get(JWKS_URL, timeout=10.0) + resp.raise_for_status() + return resp.json() def get_public_key(token): unverified_header = jwt.get_unverified_header(token) - for key in jwks["keys"]: + for key in get_jwks().get("keys", []): if key["kid"] == unverified_header["kid"]: return RSAAlgorithm.from_jwk(key) raise HTTPException(status_code=401, detail="Invalid signing key") diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index 1a468b13..1bae81d9 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -149,6 +149,29 @@ resources: table: ogc_depth_to_water_trend_wells geom_field: point + water_elevation_wells: + type: collection + title: Water Elevation (Water Wells) + description: Most recent water elevation per well calculated as elevation minus depth to water below ground surface. + keywords: [water-wells, groundwater-level, water-elevation, depth-to-water] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_water_elevation_wells + geom_field: point + water_well_summary: type: collection title: Water Well Summary @@ -172,4 +195,96 @@ resources: table: ogc_water_well_summary geom_field: point + major_chemistry_results: + type: collection + title: Major Chemistry (Water Wells) + description: Latest major chemistry analyte values for water wells, represented as static analyte columns. + keywords: [water-wells, chemistry, analytes, major-chemistry] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_major_chemistry_results + geom_field: point + + minor_chemistry_wells: + type: collection + title: Minor Chemistry (Water Wells) + description: Latest minor/trace chemistry analyte values for water wells, represented as static analyte columns. + keywords: [water-wells, chemistry, analytes, minor-chemistry, trace-chemistry] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_minor_chemistry_wells + geom_field: point + + actively_monitored_wells: + type: collection + title: Actively Monitored Wells + description: Wells in the collaborative network currently flagged as actively monitored. + keywords: [water-wells, monitoring, collaborative-network, actively-monitored] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_actively_monitored_wells + geom_field: point + + project_areas: + type: collection + title: Project Areas + description: Project groups with polygon project-area boundaries. + keywords: [project-areas, groups, boundaries] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_project_areas + geom_field: project_area + {thing_collections_block} diff --git a/core/pygeoapi.py b/core/pygeoapi.py index 6c679a21..7783af10 100644 --- a/core/pygeoapi.py +++ b/core/pygeoapi.py @@ -1,5 +1,7 @@ +import importlib import os import re +import sys import textwrap from importlib.util import find_spec from pathlib import Path @@ -12,28 +14,38 @@ "id": "water_wells", "title": "Water Wells", "thing_type": "water well", - "description": "Groundwater wells used for monitoring, production, and hydrogeologic investigations.", + "description": ( + "Groundwater wells used for monitoring, production, and " + "hydrogeologic investigations." + ), "keywords": ["well", "groundwater", "water-well"], }, { "id": "springs", "title": "Springs", "thing_type": "spring", - "description": "Natural spring features and associated spring monitoring points.", + "description": ( + "Natural spring features and associated spring monitoring points." + ), "keywords": ["springs", "groundwater-discharge"], }, { "id": "diversions_surface_water", "title": "Surface Water Diversions", "thing_type": "diversion of surface water, etc.", - "description": "Diversion structures such as ditches, canals, and intake points.", + "description": ( + "Diversion structures such as ditches, canals, and intake points." + ), "keywords": ["surface-water", "diversion"], }, { "id": "ephemeral_streams", "title": "Ephemeral Streams", "thing_type": "ephemeral stream", - "description": "Stream reaches that flow only in direct response to precipitation events.", + "description": ( + "Stream reaches that flow only in direct response to " + "precipitation events." + ), "keywords": ["ephemeral-stream", "surface-water"], }, { @@ -54,7 +66,9 @@ "id": "other_things", "title": "Other Thing Types", "thing_type": "other", - "description": "Feature records that do not match another defined thing type.", + "description": ( + "Feature records that do not match another defined thing type." + ), "keywords": ["other"], }, { @@ -68,100 +82,25 @@ "id": "perennial_streams", "title": "Perennial Streams", "thing_type": "perennial stream", - "description": "Stream reaches with continuous or near-continuous flow.", + "description": ("Stream reaches with continuous or near-continuous flow."), "keywords": ["perennial-stream", "surface-water"], }, { "id": "rock_sample_locations", "title": "Rock Sample Locations", "thing_type": "rock sample location", - "description": "Locations where rock samples were collected or documented.", + "description": ("Locations where rock samples were collected or documented."), "keywords": ["rock-sample"], }, { "id": "soil_gas_sample_locations", "title": "Soil Gas Sample Locations", "thing_type": "soil gas sample location", - "description": "Locations where soil gas measurements or samples were collected.", + "description": ( + "Locations where soil gas measurements or samples were collected." + ), "keywords": ["soil-gas", "sample-location"], }, - { - "id": "abandoned_wells", - "title": "Abandoned Wells", - "thing_type": "abandoned well", - "description": "Wells that are no longer active and are classified as abandoned.", - "keywords": ["abandoned-well", "well"], - }, - { - "id": "artesian_wells", - "title": "Artesian Wells", - "thing_type": "artesian well", - "description": "Wells that tap confined aquifers with artesian pressure conditions.", - "keywords": ["artesian", "well"], - }, - { - "id": "dry_holes", - "title": "Dry Holes", - "thing_type": "dry hole", - "description": "Drilled holes that did not produce usable groundwater.", - "keywords": ["dry-hole", "well"], - }, - { - "id": "dug_wells", - "title": "Dug Wells", - "thing_type": "dug well", - "description": "Large-diameter wells excavated by digging.", - "keywords": ["dug-well", "well"], - }, - { - "id": "exploration_wells", - "title": "Exploration Wells", - "thing_type": "exploration well", - "description": "Wells drilled to characterize geologic and groundwater conditions.", - "keywords": ["exploration-well", "well"], - }, - { - "id": "injection_wells", - "title": "Injection Wells", - "thing_type": "injection well", - "description": "Wells used to inject fluids into subsurface formations.", - "keywords": ["injection-well", "well"], - }, - { - "id": "monitoring_wells", - "title": "Monitoring Wells", - "thing_type": "monitoring well", - "description": "Wells primarily used for long-term groundwater monitoring.", - "keywords": ["monitoring-well", "groundwater", "well"], - }, - { - "id": "observation_wells", - "title": "Observation Wells", - "thing_type": "observation well", - "description": "Observation wells used for periodic water-level measurements.", - "keywords": ["observation-well", "groundwater", "well"], - }, - { - "id": "piezometers", - "title": "Piezometers", - "thing_type": "piezometer", - "description": "Piezometers used to measure hydraulic head at depth.", - "keywords": ["piezometer", "groundwater", "well"], - }, - { - "id": "production_wells", - "title": "Production Wells", - "thing_type": "production well", - "description": "Wells used for groundwater supply and extraction.", - "keywords": ["production-well", "groundwater", "well"], - }, - { - "id": "test_wells", - "title": "Test Wells", - "thing_type": "test well", - "description": "Temporary or investigative test wells.", - "keywords": ["test-well", "well"], - }, ] @@ -181,7 +120,8 @@ def _mount_path() -> str: if not path.startswith("/"): path = f"/{path}" - # Remove any trailing slashes so "/ogcapi/" and "ogcapi/" both become "/ogcapi". + # Remove trailing slashes so "/ogcapi/" and "ogcapi/" both become + # "/ogcapi". path = path.rstrip("/") # Disallow traversal/current-directory segments. @@ -191,7 +131,8 @@ def _mount_path() -> str: "Invalid PYGEOAPI_MOUNT_PATH: traversal segments are not allowed." ) - # Allow only slash-delimited segments of alphanumerics, underscore, or hyphen. + # Allow only slash-delimited segments of alphanumerics, underscore, + # or hyphen. if not re.fullmatch(r"/[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*", path): raise ValueError( "Invalid PYGEOAPI_MOUNT_PATH: only letters, numbers, underscores, " @@ -285,8 +226,8 @@ def _pygeoapi_db_settings() -> tuple[str, str, str, str, str]: ).strip() if not user: raise RuntimeError( - "PYGEOAPI_POSTGRES_USER or POSTGRES_USER must be set and non-empty " - "to generate the pygeoapi configuration." + "PYGEOAPI_POSTGRES_USER or POSTGRES_USER must be set and " + "non-empty to generate the pygeoapi configuration." ) if os.environ.get("PYGEOAPI_POSTGRES_PASSWORD") is None: raise RuntimeError( @@ -338,12 +279,22 @@ def _generate_openapi(config_path: Path, openapi_path: Path) -> None: openapi_path.write_text(openapi, encoding="utf-8") +def _load_pygeoapi_app(): + module_name = "pygeoapi.starlette_app" + if module_name in sys.modules: + module = importlib.reload(sys.modules[module_name]) + else: + module = importlib.import_module(module_name) + return module.APP + + def mount_pygeoapi(app: FastAPI) -> None: if getattr(app.state, "pygeoapi_mounted", False): return if find_spec("pygeoapi") is None: raise RuntimeError( - "pygeoapi is not installed. Rebuild/sync dependencies so /ogcapi can be mounted." + "pygeoapi is not installed. Rebuild/sync dependencies so " + "/ogcapi can be mounted." ) pygeoapi_dir = _pygeoapi_dir() @@ -355,8 +306,7 @@ def mount_pygeoapi(app: FastAPI) -> None: os.environ["PYGEOAPI_CONFIG"] = str(config_path) os.environ["PYGEOAPI_OPENAPI"] = str(openapi_path) - from pygeoapi.starlette_app import APP as pygeoapi_app - + pygeoapi_app = _load_pygeoapi_app() mount_path = _mount_path() app.mount(mount_path, pygeoapi_app) diff --git a/db/engine.py b/db/engine.py index 6e1bfd17..2d2f0d9f 100644 --- a/db/engine.py +++ b/db/engine.py @@ -16,24 +16,74 @@ import copy import getpass +import logging import os +import time from contextlib import contextmanager from dotenv import load_dotenv -from sqlalchemy import ( - create_engine, -) +from sqlalchemy import create_engine, event from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.orm import ( sessionmaker, ) from sqlalchemy.util import await_only -from services.util import get_bool_env +from services.env import get_bool_env -# Load .env file - don't override env vars already set (e.g., by test framework) +# Load .env file. Do not override env vars already set by the runtime. load_dotenv(override=False) driver = os.environ.get("DB_DRIVER", "") +logger = logging.getLogger(__name__) + + +def is_pool_logging_enabled() -> bool: + return bool( + get_bool_env("DB_POOL_LOGGING", False) + or get_bool_env("API_DEBUG_TIMING", False) + ) + + +def _install_pool_logging(engine): + if not is_pool_logging_enabled(): + return + + @event.listens_for(engine, "checkout") + def log_checkout(dbapi_connection, connection_record, connection_proxy): + connection_record.info["checked_out_at"] = time.perf_counter() + logger.info( + "db pool checkout", + extra={ + "event": "db_pool_checkout", + "pool_status": engine.pool.status(), + }, + ) + + @event.listens_for(engine, "checkin") + def log_checkin(dbapi_connection, connection_record): + checked_out_at = connection_record.info.pop("checked_out_at", None) + hold_ms = None + if checked_out_at is not None: + hold_ms = round((time.perf_counter() - checked_out_at) * 1000, 2) + logger.info( + "db pool checkin", + extra={ + "event": "db_pool_checkin", + "connection_hold_ms": hold_ms, + "pool_status": engine.pool.status(), + }, + ) + + @event.listens_for(engine, "invalidate") + def log_invalidate(dbapi_connection, connection_record, exception): + logger.warning( + "db pool invalidate", + extra={ + "event": "db_pool_invalidate", + "pool_status": engine.pool.status(), + "exception_type": (type(exception).__name__ if exception else None), + }, + ) def get_iam_login_token() -> str: @@ -85,7 +135,11 @@ def asyncify_connection(): else: connect_kwargs["password"] = password - connection = connector.connect_async(instance_name, "asyncpg", **connect_kwargs) + connection = connector.connect_async( + instance_name, + "asyncpg", + **connect_kwargs, + ) return AsyncAdapt_asyncpg_connection( engine.dialect.dbapi, @@ -133,6 +187,7 @@ def getconn(): # Configure connection pool for parallel transfers pool_size = int(os.environ.get("DB_POOL_SIZE", "10")) max_overflow = int(os.environ.get("DB_MAX_OVERFLOW", "20")) + pool_timeout = int(os.environ.get("DB_POOL_TIMEOUT", "30")) engine = create_engine( "postgresql+pg8000://", @@ -140,8 +195,10 @@ def getconn(): echo=False, pool_size=pool_size, max_overflow=max_overflow, + pool_timeout=pool_timeout, pool_pre_ping=True, ) + _install_pool_logging(engine) return engine connector = Connector() @@ -172,6 +229,7 @@ def getconn(): # max_overflow: additional connections during peak usage pool_size = int(os.environ.get("DB_POOL_SIZE", "10")) max_overflow = int(os.environ.get("DB_MAX_OVERFLOW", "20")) + pool_timeout = int(os.environ.get("DB_POOL_TIMEOUT", "30")) engine = create_engine( url, @@ -179,8 +237,10 @@ def getconn(): plugins=["geoalchemy2"], pool_size=pool_size, max_overflow=max_overflow, + pool_timeout=pool_timeout, pool_pre_ping=True, # Verify connections before use ) + _install_pool_logging(engine) async_engine = create_async_engine( url.replace("postgresql+pg8000", "postgresql+asyncpg"), diff --git a/db/transducer.py b/db/transducer.py index ae9ac01d..1670bb9f 100644 --- a/db/transducer.py +++ b/db/transducer.py @@ -107,6 +107,14 @@ class TransducerObservation(Base, AutoBaseMixin, ReleaseMixin): """ __tablename__ = "transducer_observation" + __table_args__ = ( + Index( + "ix_transducer_observation_deployment_parameter_datetime", + "deployment_id", + "parameter_id", + "observation_datetime", + ), + ) parameter_id: Mapped[int] = mapped_column( ForeignKey("parameter.id", ondelete="CASCADE"), nullable=False, index=True diff --git a/docker-compose.yml b/docker-compose.yml index 9eb88baf..78120d76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,20 +2,22 @@ services: db: - build: - context: . - dockerfile: ./docker/db/Dockerfile - platform: linux/amd64 + image: postgis/postgis:17-3.5 + # build: +# context: . +# dockerfile: ./docker/db/Dockerfile +# platform: linux/amd64 environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_DB=ocotilloapi_dev ports: - - 5432:5432 + - "5432:5432" volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data_dev:/var/lib/postgresql/data + - ./docker/db/init:/docker-entrypoint-initdb.d:ro healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ocotilloapi_dev"] interval: 2s timeout: 5s retries: 20 @@ -27,13 +29,20 @@ services: environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_DB=ocotilloapi_dev - POSTGRES_HOST=db - POSTGRES_PORT=5432 - MODE=${MODE} - AUTHENTIK_DISABLE_AUTHENTICATION=${AUTHENTIK_DISABLE_AUTHENTICATION} + - SESSION_SECRET_KEY=${SESSION_SECRET_KEY} + - PYGEOAPI_POSTGRES_HOST=db + - PYGEOAPI_POSTGRES_PORT=5432 + - PYGEOAPI_POSTGRES_DB=ocotilloapi_dev + - PYGEOAPI_POSTGRES_USER=${POSTGRES_USER} + - PYGEOAPI_POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - GOOGLE_APPLICATION_CREDENTIALS=/app/gcs_credentials.json ports: - - 8000:8000 + - "8000:8000" depends_on: db: condition: service_healthy # <-- wait for DB to be ready @@ -43,4 +52,4 @@ services: - .:/app volumes: - postgres_data: + postgres_data_dev: diff --git a/docker/db/init/01-create-test-db.sql b/docker/db/init/01-create-test-db.sql new file mode 100644 index 00000000..53ab9cb5 --- /dev/null +++ b/docker/db/init/01-create-test-db.sql @@ -0,0 +1,10 @@ +-- Initialize test database inside the same Postgres service used for dev. +-- This script runs only when the data directory is first initialized. + +CREATE DATABASE ocotilloapi_test; + +\connect ocotilloapi_dev +CREATE EXTENSION IF NOT EXISTS postgis; + +\connect ocotilloapi_test +CREATE EXTENSION IF NOT EXISTS postgis; diff --git a/entrypoint.sh b/entrypoint.sh index 66248761..c89c621c 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,12 +1,29 @@ #!/bin/sh +set -eu + +DB_HOST="${POSTGRES_HOST:-db}" +DB_PORT="${POSTGRES_PORT:-5432}" +DB_NAME="${POSTGRES_DB:-postgres}" +APP_MODULE="${APP_MODULE:-main:app}" +APP_PORT="${APP_PORT:-8000}" +RUN_MIGRATIONS="${RUN_MIGRATIONS:-true}" +UVICORN_RELOAD="${UVICORN_RELOAD:-false}" + # Wait for PostgreSQL to be ready -until PGPASSWORD="$POSTGRES_PASSWORD" pg_isready -h db -p 5432 -U "$POSTGRES_USER"; do - echo "Waiting for postgres..." +until PGPASSWORD="$POSTGRES_PASSWORD" pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$POSTGRES_USER" -d "$DB_NAME"; do + echo "Waiting for postgres at ${DB_HOST}:${DB_PORT}/${DB_NAME}..." sleep 2 done echo "PostgreSQL is ready!" -echo "Applying migrations..." -alembic upgrade head +if [ "$RUN_MIGRATIONS" = "true" ]; then + echo "Applying migrations..." + alembic upgrade head +fi + echo "Starting the application..." -uvicorn main:app --host 0.0.0.0 --port 8000 --reload \ No newline at end of file +if [ "$UVICORN_RELOAD" = "true" ]; then + uvicorn "$APP_MODULE" --host 0.0.0.0 --port "$APP_PORT" --reload +else + uvicorn "$APP_MODULE" --host 0.0.0.0 --port "$APP_PORT" +fi diff --git a/main.py b/main.py index fac816f2..8a56d312 100644 --- a/main.py +++ b/main.py @@ -1,43 +1,6 @@ -import os +from core.factory import create_api_app -from dotenv import load_dotenv - -from core.initializers import configure_admin, configure_middleware, register_routes - -load_dotenv() -DSN = os.environ.get("SENTRY_DSN") - -if DSN: - import sentry_sdk - - sentry_sdk.init( - dsn=DSN, - # Set traces_sample_rate to 1.0 to capture 100% - # of transactions for performance monitoring. - traces_sample_rate=1.0, - # Set profiles_sample_rate to 1.0 to profile 100% - # of sampled transactions. - # We recommend adjusting this value in production. - profiles_sample_rate=1.0, - # Set profile_lifecycle to "trace" to automatically - # run the profiler on when there is an active transaction - profile_lifecycle="trace", - # Add data like request headers and IP for users, - # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info - send_default_pii=True, - ) - - -def create_app(): - from core.app import app as core_app - - register_routes(core_app) - configure_middleware(core_app) - configure_admin(core_app) - return core_app - - -app = create_app() +app = create_api_app() if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 0cbf8cc1..a999e575 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,41 +7,41 @@ requires-python = ">=3.13" dependencies = [ "aiofiles==24.1.0", "aiohappyeyeballs==2.6.1", - "aiohttp==3.13.3", + "aiohttp==3.13.4", "aiosignal==1.4.0", "aiosqlite==0.22.1", "alembic==1.18.4", "annotated-types==0.7.0", - "anyio==4.12.1", - "apitally[fastapi]==0.24.1", + "anyio==4.13.0", + "apitally[fastapi]==0.24.4", "asgiref==3.11.1", "asn1crypto==1.5.1", "asyncpg==0.31.0", "attrs==25.4.0", - "authlib==1.6.8", + "authlib==1.6.9", "bcrypt==4.3.0", "cachetools==5.5.2", "certifi==2025.8.3", - "cffi==1.17.1", - "charset-normalizer==3.4.4", + "cffi==2.0.0", + "charset-normalizer==3.4.6", "click==8.3.1", - "cloud-sql-python-connector==1.20.0", - "cryptography==45.0.6", + "cloud-sql-python-connector==1.20.1", + "cryptography==46.0.7", "dnspython==2.8.0", "dotenv==0.9.9", "email-validator==2.3.0", - "fastapi==0.129.0", - "fastapi-pagination==0.15.10", + "fastapi==0.135.2", + "fastapi-pagination==0.15.12", "frozenlist==1.8.0", - "geoalchemy2==0.18.1", - "google-api-core==2.29.0", - "google-auth==2.48.0", + "geoalchemy2==0.18.4", + "google-api-core==2.30.0", + "google-auth==2.49.1", "google-cloud-core==2.5.0", - "google-cloud-storage==3.9.0", + "google-cloud-storage==3.10.1", "google-crc32c==1.8.0", "google-resumable-media==2.8.0", - "googleapis-common-protos==1.72.0", - "greenlet==3.3.1", + "googleapis-common-protos==1.73.1", + "greenlet==3.3.2", "gunicorn==23.0.0", "h11==0.16.0", "httpcore==1.0.9", @@ -53,56 +53,56 @@ dependencies = [ "mako==1.3.10", "markupsafe==3.0.3", "multidict==6.7.1", - "numpy==2.4.2", - "packaging==25.0", + "numpy==2.4.4", + "packaging==26.0", "pandas==2.3.2", "pandas-stubs~=2.3.2", "pg8000==1.31.5", - "phonenumbers==9.0.24", - "pillow==11.3.0", + "phonenumbers==9.0.26", + "pillow==12.1.1", "pluggy==1.6.0", "pre-commit==4.5.1", "propcache==0.4.1", - "proto-plus==1.27.1", + "proto-plus==1.27.2", "protobuf==6.33.5", "psycopg2-binary>=2.9.10", - "pyasn1==0.6.2", + "pyasn1==0.6.3", "pyasn1-modules==0.4.2", - "pycparser==2.23", + "pycparser==3.0", "pydantic==2.12.5", "pydantic-core==2.41.5", - "pygments==2.19.2", - "pyjwt==2.11.0", + "pygments==2.20.0", + "pyjwt==2.12.1", "pygeoapi==0.22.0", "pyproj==3.7.2", "pyshp==2.3.1", - "pytest==9.0.2", + "pytest==9.0.3", "pytest-cov==6.2.1", "python-dateutil==2.9.0.post0", "python-jose>=3.5.0", "python-multipart==0.0.22", "pytz==2025.2", - "requests==2.32.5", + "requests==2.33.1", "rsa==4.9.1", "scramp==1.4.8", - "sentry-sdk[fastapi]==2.53.0", + "sentry-sdk[fastapi]==2.56.0", "shapely==2.1.2", "six==1.17.0", "sniffio==1.3.1", - "sqlalchemy==2.0.46", + "sqlalchemy==2.0.48", "sqlalchemy-continuum==1.6.0", "sqlalchemy-searchable==2.1.0", "sqlalchemy-utils==0.42.1", "starlette==0.52.1", "starlette-admin[i18n]==0.16.0", - "typer==0.23.1", + "typer==0.24.1", "typing-extensions==4.15.0", "typing-inspection==0.4.2", "tzdata==2025.3", "urllib3==2.6.3", "utm==0.8.1", - "uvicorn==0.40.0", - "yarl==1.22.0", + "uvicorn==0.42.0", + "yarl==1.23.0", ] [tool.uv] @@ -138,7 +138,7 @@ dev = [ "faker>=25.0.0", "flake8>=7.3.0", "pyhamcrest>=2.0.3", - "pytest>=8.4.0", + "pytest>=9.0.3", "python-dotenv>=1.1.1", "requests>=2.32.5", ] diff --git a/requirements.txt b/requirements.txt index 24cd75ff..1bd3bb28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,59 +16,127 @@ aiohappyeyeballs==2.6.1 \ # via # aiohttp # ocotilloapi -aiohttp==3.13.3 \ - --hash=sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c \ - --hash=sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c \ - --hash=sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f \ - --hash=sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2 \ - --hash=sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf \ - --hash=sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998 \ - --hash=sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767 \ - --hash=sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43 \ - --hash=sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592 \ - --hash=sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a \ - --hash=sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687 \ - --hash=sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8 \ - --hash=sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261 \ - --hash=sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4 \ - --hash=sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587 \ - --hash=sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91 \ - --hash=sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3 \ - --hash=sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344 \ - --hash=sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6 \ - --hash=sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3 \ - --hash=sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29 \ - --hash=sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c \ - --hash=sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926 \ - --hash=sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64 \ - --hash=sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e \ - --hash=sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6 \ - --hash=sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d \ - --hash=sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415 \ - --hash=sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603 \ - --hash=sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0 \ - --hash=sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf \ - --hash=sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591 \ - --hash=sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26 \ - --hash=sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a \ - --hash=sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9 \ - --hash=sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba \ - --hash=sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df \ - --hash=sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984 \ - --hash=sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632 \ - --hash=sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56 \ - --hash=sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88 \ - --hash=sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc \ - --hash=sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0 \ - --hash=sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1 \ - --hash=sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25 \ - --hash=sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1 \ - --hash=sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f \ - --hash=sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72 \ - --hash=sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808 \ - --hash=sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0 \ - --hash=sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730 \ - --hash=sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa +aiohttp==3.13.4 \ + --hash=sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144 \ + --hash=sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9 \ + --hash=sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed \ + --hash=sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182 \ + --hash=sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576 \ + --hash=sha256:0d0dbc6c76befa76865373d6aa303e480bb8c3486e7763530f7f6e527b471118 \ + --hash=sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965 \ + --hash=sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e \ + --hash=sha256:10fb7b53262cf4144a083c9db0d2b4d22823d6708270a9970c4627b248c6064c \ + --hash=sha256:13168f5645d9045522c6cef818f54295376257ed8d02513a37c2ef3046fc7a97 \ + --hash=sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e \ + --hash=sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933 \ + --hash=sha256:1746338dc2a33cf706cd7446575d13d451f28f9860bebc908c7632b22e71ae3f \ + --hash=sha256:1867087e2c1963db1216aedf001efe3b129835ed2b05d97d058176a6d08b5726 \ + --hash=sha256:19f60011ad60e40a01d242238bb335399e3a4d8df958c63cbb835add8d5c3b5a \ + --hash=sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5 \ + --hash=sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871 \ + --hash=sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758 \ + --hash=sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0 \ + --hash=sha256:26ed03f7d3d6453634729e2c7600d7255d65e879559c5a48fe1bb78355cde74b \ + --hash=sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7 \ + --hash=sha256:2d15e7e4f1099d9e4d863eaf77a8eee5dcb002b7d7188061b0fbee37f845899e \ + --hash=sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e \ + --hash=sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f \ + --hash=sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d \ + --hash=sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927 \ + --hash=sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7 \ + --hash=sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3 \ + --hash=sha256:383880f7b8de5ac208fa829c7038d08e66377283b2de9e791b71e06e803153c2 \ + --hash=sha256:3b4e07d8803a70dd886b5f38588e5b49f894995ca8e132b06c31a2583ae2ef6e \ + --hash=sha256:3cdd3393130bf6588962441ffd5bde1d3ea2d63a64afa7119b3f3ba349cebbe7 \ + --hash=sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9 \ + --hash=sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329 \ + --hash=sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1 \ + --hash=sha256:463fa18a95c5a635d2b8c09babe240f9d7dbf2a2010a6c0b35d8c4dff2a0e819 \ + --hash=sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42 \ + --hash=sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70 \ + --hash=sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7 \ + --hash=sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9 \ + --hash=sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2 \ + --hash=sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c \ + --hash=sha256:4c3f733916e85506b8000dddc071c6b82f8c68f56c99adb328d6550017db062d \ + --hash=sha256:4e2e68085730a03704beb2cff035fa8648f62c9f93758d7e6d70add7f7bb5b3b \ + --hash=sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a \ + --hash=sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3 \ + --hash=sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e \ + --hash=sha256:5539ec0d6a3a5c6799b661b7e79166ad1b7ae71ccb59a92fcb6b4ef89295bc94 \ + --hash=sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2 \ + --hash=sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d \ + --hash=sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be \ + --hash=sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77 \ + --hash=sha256:6234bf416a38d687c3ab7f79934d7fb2a42117a5b9813aca07de0a5398489023 \ + --hash=sha256:6290fe12fe8cefa6ea3c1c5b969d32c010dfe191d4392ff9b599a3f473cbe722 \ + --hash=sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538 \ + --hash=sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453 \ + --hash=sha256:6b335919ffbaf98df8ff3c74f7a6decb8775882632952fd1810a017e38f15aee \ + --hash=sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f \ + --hash=sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b \ + --hash=sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7 \ + --hash=sha256:717d17347567ded1e273aa09918650dfd6fd06f461549204570c7973537d4123 \ + --hash=sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e \ + --hash=sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3 \ + --hash=sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c \ + --hash=sha256:7520d92c0e8fbbe63f36f20a5762db349ff574ad38ad7bc7732558a650439845 \ + --hash=sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a \ + --hash=sha256:797613182ffaaca0b9ad5f3b3d3ce5d21242c768f75e66c750b8292bd97c9de3 \ + --hash=sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763 \ + --hash=sha256:7c65738ac5ae32b8feef699a4ed0dc91a0c8618b347781b7461458bbcaaac7eb \ + --hash=sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c \ + --hash=sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83 \ + --hash=sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8 \ + --hash=sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942 \ + --hash=sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab \ + --hash=sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1 \ + --hash=sha256:907ad36b6a65cff7d88d7aca0f77c650546ba850a4f92c92ecb83590d4613249 \ + --hash=sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073 \ + --hash=sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde \ + --hash=sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27 \ + --hash=sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c \ + --hash=sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500 \ + --hash=sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069 \ + --hash=sha256:a5444dce2e6fba0a1dc2d58d026e674f25f21de178c6f844342629bcef019f2f \ + --hash=sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9 \ + --hash=sha256:a7058af1f53209fdf07745579ced525d38d481650a989b7aa4a3b484b901cdab \ + --hash=sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d \ + --hash=sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21 \ + --hash=sha256:b3d525648fe7c8b4977e460c18098f9f81d7991d72edfdc2f13cf96068f279bc \ + --hash=sha256:b3f00bb9403728b08eb3951e982ca0a409c7a871d709684623daeab79465b181 \ + --hash=sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8 \ + --hash=sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb \ + --hash=sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3 \ + --hash=sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145 \ + --hash=sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d \ + --hash=sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165 \ + --hash=sha256:c344c47e85678e410b064fc2ace14db86bb69db7ed5520c234bf13aed603ec30 \ + --hash=sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8 \ + --hash=sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30 \ + --hash=sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954 \ + --hash=sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7 \ + --hash=sha256:cb15595eb52870f84248d7cc97013a76f52ab02ff74d394be093b1d9b8b82bc0 \ + --hash=sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba \ + --hash=sha256:ce7320a945aac4bf0bb8901600e4f9409eb602f25ce3ef4d275b48f6d704a862 \ + --hash=sha256:d2710ae1e1b81d0f187883b6e9d66cecf8794b50e91aa1e73fc78bfb5503b5d9 \ + --hash=sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349 \ + --hash=sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393 \ + --hash=sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97 \ + --hash=sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12 \ + --hash=sha256:d904084985ca66459e93797e5e05985c048a9c0633655331144c089943e53d12 \ + --hash=sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38 \ + --hash=sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b \ + --hash=sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551 \ + --hash=sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57 \ + --hash=sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c \ + --hash=sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb \ + --hash=sha256:eb10ce8c03850e77f4d9518961c227be569e12f71525a7e90d17bca04299921d \ + --hash=sha256:ec75fc18cb9f4aca51c2cbace20cf6716e36850f44189644d2d69a875d5e0532 \ + --hash=sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360 \ + --hash=sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f \ + --hash=sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c \ + --hash=sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791 # via # cloud-sql-python-connector # ocotilloapi @@ -98,16 +166,16 @@ annotated-types==0.7.0 \ # via # ocotilloapi # pydantic -anyio==4.12.1 \ - --hash=sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703 \ - --hash=sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c +anyio==4.13.0 \ + --hash=sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708 \ + --hash=sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc # via # httpx # ocotilloapi # starlette -apitally==0.24.1 \ - --hash=sha256:18d476871e081ff8f42fd0b631b33ccaf631be404abe9a54e30621117389a70e \ - --hash=sha256:90adc1ad7698e83833622f4673e72c46e39c9474385a891dd3ce4e413c1f0863 +apitally==0.24.4 \ + --hash=sha256:764f3c9dc907ec2014f8f420d66db091826106eb7b306ce871238c647029a019 \ + --hash=sha256:78447204cb1b0e6b409129ae8b13ddcdfe03bab648af8662cd73fc24a8e30ec2 # via ocotilloapi asgiref==3.11.1 \ --hash=sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce \ @@ -155,13 +223,13 @@ attrs==25.4.0 \ # ocotilloapi # rasterio # referencing -authlib==1.6.8 \ - --hash=sha256:41ae180a17cf672bc784e4a518e5c82687f1fe1e98b0cafaeda80c8e4ab2d1cb \ - --hash=sha256:97286fd7a15e6cfefc32771c8ef9c54f0ed58028f1322de6a2a7c969c3817888 +authlib==1.6.9 \ + --hash=sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04 \ + --hash=sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3 # via ocotilloapi -babel==2.17.0 \ - --hash=sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d \ - --hash=sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2 +babel==2.18.0 \ + --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ + --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 # via # pygeoapi # starlette-admin @@ -233,61 +301,228 @@ certifi==2025.8.3 \ # rasterio # requests # sentry-sdk -cffi==1.17.1 \ - --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \ - --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \ - --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \ - --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \ - --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \ - --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \ - --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \ - --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \ - --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \ - --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \ - --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \ - --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a +cffi==2.0.0 \ + --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ + --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ + --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ + --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ + --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ + --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ + --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ + --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ + --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ + --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ + --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ + --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ + --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ + --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ + --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ + --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ + --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ + --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ + --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ + --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ + --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ + --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ + --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ + --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ + --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ + --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ + --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ + --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ + --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ + --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ + --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ + --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ + --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ + --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ + --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ + --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ + --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ + --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ + --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ + --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ + --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ + --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ + --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ + --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ + --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ + --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ + --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ + --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ + --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ + --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ + --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ + --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ + --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ + --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ + --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ + --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ + --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ + --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ + --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ + --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ + --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ + --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ + --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ + --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ + --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ + --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ + --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ + --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ + --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ + --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ + --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ + --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ + --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ + --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ + --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ + --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ + --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ + --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ + --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ + --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ + --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ + --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ + --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ + --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf # via # cryptography # ocotilloapi -cfgv==3.4.0 \ - --hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \ - --hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560 +cfgv==3.5.0 \ + --hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \ + --hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132 # via pre-commit -charset-normalizer==3.4.4 \ - --hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \ - --hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \ - --hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \ - --hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \ - --hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \ - --hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \ - --hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \ - --hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \ - --hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \ - --hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \ - --hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \ - --hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \ - --hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \ - --hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \ - --hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \ - --hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \ - --hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \ - --hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \ - --hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \ - --hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \ - --hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \ - --hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \ - --hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \ - --hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \ - --hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \ - --hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \ - --hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \ - --hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \ - --hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \ - --hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \ - --hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \ - --hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \ - --hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \ - --hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9 +charset-normalizer==3.4.6 \ + --hash=sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e \ + --hash=sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c \ + --hash=sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5 \ + --hash=sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815 \ + --hash=sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f \ + --hash=sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0 \ + --hash=sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484 \ + --hash=sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407 \ + --hash=sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6 \ + --hash=sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8 \ + --hash=sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264 \ + --hash=sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815 \ + --hash=sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2 \ + --hash=sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4 \ + --hash=sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579 \ + --hash=sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f \ + --hash=sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa \ + --hash=sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95 \ + --hash=sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab \ + --hash=sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297 \ + --hash=sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a \ + --hash=sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e \ + --hash=sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84 \ + --hash=sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8 \ + --hash=sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0 \ + --hash=sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9 \ + --hash=sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f \ + --hash=sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1 \ + --hash=sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843 \ + --hash=sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565 \ + --hash=sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7 \ + --hash=sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c \ + --hash=sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b \ + --hash=sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7 \ + --hash=sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687 \ + --hash=sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9 \ + --hash=sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14 \ + --hash=sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89 \ + --hash=sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f \ + --hash=sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0 \ + --hash=sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9 \ + --hash=sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a \ + --hash=sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389 \ + --hash=sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0 \ + --hash=sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30 \ + --hash=sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd \ + --hash=sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e \ + --hash=sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9 \ + --hash=sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc \ + --hash=sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532 \ + --hash=sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d \ + --hash=sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae \ + --hash=sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2 \ + --hash=sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64 \ + --hash=sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f \ + --hash=sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557 \ + --hash=sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e \ + --hash=sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff \ + --hash=sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398 \ + --hash=sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db \ + --hash=sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a \ + --hash=sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43 \ + --hash=sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597 \ + --hash=sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c \ + --hash=sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e \ + --hash=sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2 \ + --hash=sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54 \ + --hash=sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e \ + --hash=sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4 \ + --hash=sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4 \ + --hash=sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7 \ + --hash=sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6 \ + --hash=sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5 \ + --hash=sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194 \ + --hash=sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69 \ + --hash=sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f \ + --hash=sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316 \ + --hash=sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e \ + --hash=sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73 \ + --hash=sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8 \ + --hash=sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923 \ + --hash=sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88 \ + --hash=sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f \ + --hash=sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21 \ + --hash=sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4 \ + --hash=sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6 \ + --hash=sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc \ + --hash=sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2 \ + --hash=sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866 \ + --hash=sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021 \ + --hash=sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2 \ + --hash=sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d \ + --hash=sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8 \ + --hash=sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de \ + --hash=sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237 \ + --hash=sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4 \ + --hash=sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778 \ + --hash=sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb \ + --hash=sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc \ + --hash=sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602 \ + --hash=sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4 \ + --hash=sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f \ + --hash=sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5 \ + --hash=sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611 \ + --hash=sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8 \ + --hash=sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf \ + --hash=sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d \ + --hash=sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b \ + --hash=sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db \ + --hash=sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e \ + --hash=sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077 \ + --hash=sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd \ + --hash=sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef \ + --hash=sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e \ + --hash=sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8 \ + --hash=sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe \ + --hash=sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058 \ + --hash=sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17 \ + --hash=sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833 \ + --hash=sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421 \ + --hash=sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550 \ + --hash=sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff \ + --hash=sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2 \ + --hash=sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc \ + --hash=sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982 \ + --hash=sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d \ + --hash=sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed \ + --hash=sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104 \ + --hash=sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659 # via # ocotilloapi # requests @@ -307,9 +542,9 @@ cligj==0.7.2 \ --hash=sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27 \ --hash=sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df # via rasterio -cloud-sql-python-connector==1.20.0 \ - --hash=sha256:aa7c30631c5f455d14d561d7b0b414a97652a1b582a301f5570ba2cea2aa9105 \ - --hash=sha256:fdd96153b950040b0252453115604c142922b72cf3636146165a648ac5f6fc30 +cloud-sql-python-connector==1.20.1 \ + --hash=sha256:7e826875c5c284e1dfd872ab81d8c75eb82dd67ad1bbf43b9e74489342765255 \ + --hash=sha256:c00f9d81205eb658fe06f9f353e00646eb3f55d2d86de01dc1222eec1f5f2fc9 # via ocotilloapi colorama==0.4.6 ; sys_platform == 'win32' \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ @@ -317,88 +552,172 @@ colorama==0.4.6 ; sys_platform == 'win32' \ # via # click # pytest -coverage==7.10.2 \ - --hash=sha256:0100b19f230df72c90fdb36db59d3f39232391e8d89616a7de30f677da4f532b \ - --hash=sha256:04c74f9ef1f925456a9fd23a7eef1103126186d0500ef9a0acb0bd2514bdc7cc \ - --hash=sha256:11333094c1bff621aa811b67ed794865cbcaa99984dedea4bd9cf780ad64ecba \ - --hash=sha256:12e52b5aa00aa720097d6947d2eb9e404e7c1101ad775f9661ba165ed0a28303 \ - --hash=sha256:14fb5b6641ab5b3c4161572579f0f2ea8834f9d3af2f7dd8fbaecd58ef9175cc \ - --hash=sha256:1a2e934e9da26341d342d30bfe91422bbfdb3f1f069ec87f19b2909d10d8dcc4 \ - --hash=sha256:228946da741558904e2c03ce870ba5efd9cd6e48cbc004d9a27abee08100a15a \ - --hash=sha256:248b5394718e10d067354448dc406d651709c6765669679311170da18e0e9af8 \ - --hash=sha256:2d358f259d8019d4ef25d8c5b78aca4c7af25e28bd4231312911c22a0e824a57 \ - --hash=sha256:2e980e4179f33d9b65ac4acb86c9c0dde904098853f27f289766657ed16e07b3 \ - --hash=sha256:5250bda76e30382e0a2dcd68d961afcab92c3a7613606e6269855c6979a1b0bb \ - --hash=sha256:52d708b5fd65589461381fa442d9905f5903d76c086c6a4108e8e9efdca7a7ed \ - --hash=sha256:5b9d538e8e04916a5df63052d698b30c74eb0174f2ca9cd942c981f274a18eaf \ - --hash=sha256:5c61675a922b569137cf943770d7ad3edd0202d992ce53ac328c5ff68213ccf4 \ - --hash=sha256:5d6e6d84e6dd31a8ded64759626627247d676a23c1b892e1326f7c55c8d61055 \ - --hash=sha256:651015dcd5fd9b5a51ca79ece60d353cacc5beaf304db750407b29c89f72fe2b \ - --hash=sha256:65b451949cb789c346f9f9002441fc934d8ccedcc9ec09daabc2139ad13853f7 \ - --hash=sha256:6eb586fa7d2aee8d65d5ae1dd71414020b2f447435c57ee8de8abea0a77d5074 \ - --hash=sha256:718044729bf1fe3e9eb9f31b52e44ddae07e434ec050c8c628bf5adc56fe4bdd \ - --hash=sha256:71d40b3ac0f26fa9ffa6ee16219a714fed5c6ec197cdcd2018904ab5e75bcfa3 \ - --hash=sha256:75cc1a3f8c88c69bf16a871dab1fe5a7303fdb1e9f285f204b60f1ee539b8fc0 \ - --hash=sha256:81bf6a32212f9f66da03d63ecb9cd9bd48e662050a937db7199dbf47d19831de \ - --hash=sha256:835f39e618099325e7612b3406f57af30ab0a0af350490eff6421e2e5f608e46 \ - --hash=sha256:8f34b09f68bdadec122ffad312154eda965ade433559cc1eadd96cca3de5c824 \ - --hash=sha256:916369b3b914186b2c5e5ad2f7264b02cff5df96cdd7cdad65dccd39aa5fd9f0 \ - --hash=sha256:95db3750dd2e6e93d99fa2498f3a1580581e49c494bddccc6f85c5c21604921f \ - --hash=sha256:95e23987b52d02e7c413bf2d6dc6288bd5721beb518052109a13bfdc62c8033b \ - --hash=sha256:96e5921342574a14303dfdb73de0019e1ac041c863743c8fe1aa6c2b4a257226 \ - --hash=sha256:9c1cd71483ea78331bdfadb8dcec4f4edfb73c7002c1206d8e0af6797853f5be \ - --hash=sha256:9f75dbf4899e29a37d74f48342f29279391668ef625fdac6d2f67363518056a1 \ - --hash=sha256:a3e853cc04987c85ec410905667eed4bf08b1d84d80dfab2684bb250ac8da4f6 \ - --hash=sha256:a7df481e7508de1c38b9b8043da48d94931aefa3e32b47dd20277e4978ed5b95 \ - --hash=sha256:a91e027d66eff214d88d9afbe528e21c9ef1ecdf4956c46e366c50f3094696d0 \ - --hash=sha256:abb57fdd38bf6f7dcc66b38dafb7af7c5fdc31ac6029ce373a6f7f5331d6f60f \ - --hash=sha256:aca7b5645afa688de6d4f8e89d30c577f62956fefb1bad021490d63173874186 \ - --hash=sha256:c2e117e64c26300032755d4520cd769f2623cde1a1d1c3515b05a3b8add0ade1 \ - --hash=sha256:ca07fa78cc9d26bc8c4740de1abd3489cf9c47cc06d9a8ab3d552ff5101af4c0 \ - --hash=sha256:d800705f6951f75a905ea6feb03fff8f3ea3468b81e7563373ddc29aa3e5d1ca \ - --hash=sha256:daaf98009977f577b71f8800208f4d40d4dcf5c2db53d4d822787cdc198d76e1 \ - --hash=sha256:e8415918856a3e7d57a4e0ad94651b761317de459eb74d34cc1bb51aad80f07e \ - --hash=sha256:e96649ac34a3d0e6491e82a2af71098e43be2874b619547c3282fc11d3840a4b \ - --hash=sha256:ea8d8fe546c528535c761ba424410bbeb36ba8a0f24be653e94b70c93fd8a8ca \ - --hash=sha256:f256173b48cc68486299d510a3e729a96e62c889703807482dbf56946befb5c8 \ - --hash=sha256:f287a25a8ca53901c613498e4a40885b19361a2fe8fbfdbb7f8ef2cad2a23f03 \ - --hash=sha256:f35481d42c6d146d48ec92d4e239c23f97b53a3f1fbd2302e7c64336f28641fe \ - --hash=sha256:fe024d40ac31eb8d5aae70215b41dafa264676caa4404ae155f77d2fa95c37bb +coverage==7.13.5 \ + --hash=sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256 \ + --hash=sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b \ + --hash=sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5 \ + --hash=sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d \ + --hash=sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a \ + --hash=sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969 \ + --hash=sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642 \ + --hash=sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87 \ + --hash=sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740 \ + --hash=sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215 \ + --hash=sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d \ + --hash=sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422 \ + --hash=sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8 \ + --hash=sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911 \ + --hash=sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b \ + --hash=sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587 \ + --hash=sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8 \ + --hash=sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606 \ + --hash=sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9 \ + --hash=sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf \ + --hash=sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633 \ + --hash=sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6 \ + --hash=sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43 \ + --hash=sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2 \ + --hash=sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61 \ + --hash=sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930 \ + --hash=sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc \ + --hash=sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247 \ + --hash=sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75 \ + --hash=sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e \ + --hash=sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376 \ + --hash=sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01 \ + --hash=sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1 \ + --hash=sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3 \ + --hash=sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743 \ + --hash=sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9 \ + --hash=sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf \ + --hash=sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e \ + --hash=sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1 \ + --hash=sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd \ + --hash=sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b \ + --hash=sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab \ + --hash=sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d \ + --hash=sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a \ + --hash=sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0 \ + --hash=sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510 \ + --hash=sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f \ + --hash=sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0 \ + --hash=sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8 \ + --hash=sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf \ + --hash=sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209 \ + --hash=sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9 \ + --hash=sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3 \ + --hash=sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3 \ + --hash=sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d \ + --hash=sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd \ + --hash=sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2 \ + --hash=sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882 \ + --hash=sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09 \ + --hash=sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea \ + --hash=sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c \ + --hash=sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562 \ + --hash=sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3 \ + --hash=sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806 \ + --hash=sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e \ + --hash=sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878 \ + --hash=sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e \ + --hash=sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9 \ + --hash=sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45 \ + --hash=sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29 \ + --hash=sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4 \ + --hash=sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c \ + --hash=sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479 \ + --hash=sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400 \ + --hash=sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c \ + --hash=sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a \ + --hash=sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf \ + --hash=sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686 \ + --hash=sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de \ + --hash=sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028 \ + --hash=sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0 \ + --hash=sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179 \ + --hash=sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16 \ + --hash=sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85 \ + --hash=sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a \ + --hash=sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0 \ + --hash=sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810 \ + --hash=sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161 \ + --hash=sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607 \ + --hash=sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26 \ + --hash=sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819 \ + --hash=sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40 \ + --hash=sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5 \ + --hash=sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15 \ + --hash=sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0 \ + --hash=sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90 \ + --hash=sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0 \ + --hash=sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6 \ + --hash=sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a \ + --hash=sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58 \ + --hash=sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b \ + --hash=sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17 \ + --hash=sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5 \ + --hash=sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664 \ + --hash=sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0 \ + --hash=sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f # via pytest-cov -cryptography==45.0.6 \ - --hash=sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5 \ - --hash=sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74 \ - --hash=sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394 \ - --hash=sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301 \ - --hash=sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08 \ - --hash=sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3 \ - --hash=sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b \ - --hash=sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402 \ - --hash=sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3 \ - --hash=sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0 \ - --hash=sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f \ - --hash=sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3 \ - --hash=sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9 \ - --hash=sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5 \ - --hash=sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719 \ - --hash=sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02 \ - --hash=sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2 \ - --hash=sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec \ - --hash=sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159 \ - --hash=sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453 \ - --hash=sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf \ - --hash=sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9 \ - --hash=sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016 \ - --hash=sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05 \ - --hash=sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42 +cryptography==46.0.7 \ + --hash=sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65 \ + --hash=sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832 \ + --hash=sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067 \ + --hash=sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de \ + --hash=sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4 \ + --hash=sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0 \ + --hash=sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b \ + --hash=sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968 \ + --hash=sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef \ + --hash=sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b \ + --hash=sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4 \ + --hash=sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3 \ + --hash=sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308 \ + --hash=sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e \ + --hash=sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163 \ + --hash=sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f \ + --hash=sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee \ + --hash=sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77 \ + --hash=sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85 \ + --hash=sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99 \ + --hash=sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7 \ + --hash=sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83 \ + --hash=sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85 \ + --hash=sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006 \ + --hash=sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb \ + --hash=sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e \ + --hash=sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba \ + --hash=sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325 \ + --hash=sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d \ + --hash=sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1 \ + --hash=sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1 \ + --hash=sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2 \ + --hash=sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0 \ + --hash=sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455 \ + --hash=sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842 \ + --hash=sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457 \ + --hash=sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15 \ + --hash=sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2 \ + --hash=sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c \ + --hash=sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb \ + --hash=sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5 \ + --hash=sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4 \ + --hash=sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902 \ + --hash=sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246 \ + --hash=sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022 \ + --hash=sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f \ + --hash=sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e \ + --hash=sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298 \ + --hash=sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce # via # authlib # cloud-sql-python-connector # google-auth # ocotilloapi -dateparser==1.3.0 \ - --hash=sha256:5bccf5d1ec6785e5be71cc7ec80f014575a09b4923e762f850e57443bddbf1a5 \ - --hash=sha256:8dc678b0a526e103379f02ae44337d424bd366aac727d3c6cf52ce1b01efbb5a +dateparser==1.4.0 \ + --hash=sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378 \ + --hash=sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4 # via pygeofilter distlib==0.4.0 \ --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ @@ -414,29 +733,29 @@ dnspython==2.8.0 \ dotenv==0.9.9 \ --hash=sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9 # via ocotilloapi -ecdsa==0.19.1 \ - --hash=sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3 \ - --hash=sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61 +ecdsa==0.19.2 \ + --hash=sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930 \ + --hash=sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399 # via python-jose email-validator==2.3.0 \ --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 # via ocotilloapi -fastapi==0.129.0 \ - --hash=sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af \ - --hash=sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec +fastapi==0.135.2 \ + --hash=sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5 \ + --hash=sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56 # via # apitally # fastapi-pagination # ocotilloapi # sentry-sdk -fastapi-pagination==0.15.10 \ - --hash=sha256:0ba7d4f795059a91a9e89358af129f2114876452c1defaf198ea8e3419e9a3cd \ - --hash=sha256:d50071ebc93b519391f16ff6c3ba9e3603bd659963fe6774ba2f4d5037e17fd8 +fastapi-pagination==0.15.12 \ + --hash=sha256:758e21157b2844feecb2409072f1433e24f2dc9526ae7906aa1a1b28622a970a \ + --hash=sha256:914b41e07b8556de34c12d3568c9b7137eb62a3558420061a4acbebf7e729a08 # via ocotilloapi -filelock==3.18.0 \ - --hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \ - --hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de +filelock==3.25.2 \ + --hash=sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694 \ + --hash=sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70 # via # pygeoapi # virtualenv @@ -515,20 +834,20 @@ frozenlist==1.8.0 \ # aiohttp # aiosignal # ocotilloapi -geoalchemy2==0.18.1 \ - --hash=sha256:4bdc7daf659e36f6456e2f2c3bcce222b879584921a4f50a803ab05fa2bb3124 \ - --hash=sha256:a49d9559bf7acbb69129a01c6e1861657c15db420886ad0a09b1871fb0ff4bdb +geoalchemy2==0.18.4 \ + --hash=sha256:5719e2bb040d5c406d5d03425fec87997ce9351843b053ca11373a0f5a31971b \ + --hash=sha256:89e6680dcbb6b8d8c784dcaa889e48ab2783aa42487ee5730fdbd7a7c7ddf6ec # via ocotilloapi -google-api-core==2.29.0 \ - --hash=sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7 \ - --hash=sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9 +google-api-core==2.30.0 \ + --hash=sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b \ + --hash=sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5 # via # google-cloud-core # google-cloud-storage # ocotilloapi -google-auth==2.48.0 \ - --hash=sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f \ - --hash=sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce +google-auth==2.49.1 \ + --hash=sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64 \ + --hash=sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7 # via # cloud-sql-python-connector # google-api-core @@ -541,9 +860,9 @@ google-cloud-core==2.5.0 \ # via # google-cloud-storage # ocotilloapi -google-cloud-storage==3.9.0 \ - --hash=sha256:2dce75a9e8b3387078cbbdad44757d410ecdb916101f8ba308abf202b6968066 \ - --hash=sha256:f2d8ca7db2f652be757e92573b2196e10fbc09649b5c016f8b422ad593c641cc +google-cloud-storage==3.10.1 \ + --hash=sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286 \ + --hash=sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f # via ocotilloapi google-crc32c==1.8.0 \ --hash=sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa \ @@ -567,40 +886,66 @@ google-resumable-media==2.8.0 \ # via # google-cloud-storage # ocotilloapi -googleapis-common-protos==1.72.0 \ - --hash=sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038 \ - --hash=sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5 +googleapis-common-protos==1.73.1 \ + --hash=sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6 \ + --hash=sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8 # via # google-api-core # ocotilloapi -greenlet==3.3.1 \ - --hash=sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e \ - --hash=sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e \ - --hash=sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946 \ - --hash=sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d \ - --hash=sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451 \ - --hash=sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951 \ - --hash=sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f \ - --hash=sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d \ - --hash=sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242 \ - --hash=sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98 \ - --hash=sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2 \ - --hash=sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab \ - --hash=sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249 \ - --hash=sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3 \ - --hash=sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac \ - --hash=sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1 \ - --hash=sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774 \ - --hash=sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd \ - --hash=sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3 \ - --hash=sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2 \ - --hash=sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a \ - --hash=sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683 \ - --hash=sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79 \ - --hash=sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b \ - --hash=sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5 \ - --hash=sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97 \ - --hash=sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53 +greenlet==3.3.2 \ + --hash=sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd \ + --hash=sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082 \ + --hash=sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b \ + --hash=sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5 \ + --hash=sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f \ + --hash=sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727 \ + --hash=sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e \ + --hash=sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2 \ + --hash=sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f \ + --hash=sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327 \ + --hash=sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd \ + --hash=sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2 \ + --hash=sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070 \ + --hash=sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99 \ + --hash=sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be \ + --hash=sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79 \ + --hash=sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7 \ + --hash=sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e \ + --hash=sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf \ + --hash=sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f \ + --hash=sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506 \ + --hash=sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a \ + --hash=sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395 \ + --hash=sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4 \ + --hash=sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca \ + --hash=sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492 \ + --hash=sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab \ + --hash=sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358 \ + --hash=sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce \ + --hash=sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5 \ + --hash=sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef \ + --hash=sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d \ + --hash=sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac \ + --hash=sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55 \ + --hash=sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124 \ + --hash=sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4 \ + --hash=sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986 \ + --hash=sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd \ + --hash=sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f \ + --hash=sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb \ + --hash=sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4 \ + --hash=sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13 \ + --hash=sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab \ + --hash=sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff \ + --hash=sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a \ + --hash=sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9 \ + --hash=sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86 \ + --hash=sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd \ + --hash=sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71 \ + --hash=sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92 \ + --hash=sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643 \ + --hash=sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54 \ + --hash=sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9 # via # ocotilloapi # sqlalchemy @@ -627,9 +972,9 @@ httpx==0.28.1 \ # via # apitally # ocotilloapi -identify==2.6.12 \ - --hash=sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2 \ - --hash=sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6 +identify==2.6.18 \ + --hash=sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd \ + --hash=sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737 # via pre-commit idna==3.11 \ --hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \ @@ -822,77 +1167,106 @@ multidict==6.7.1 \ # aiohttp # ocotilloapi # yarl -nodeenv==1.9.1 \ - --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \ - --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9 +nodeenv==1.10.0 \ + --hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \ + --hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb # via pre-commit -numpy==2.4.2 \ - --hash=sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82 \ - --hash=sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75 \ - --hash=sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257 \ - --hash=sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71 \ - --hash=sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a \ - --hash=sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181 \ - --hash=sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef \ - --hash=sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c \ - --hash=sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e \ - --hash=sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f \ - --hash=sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b \ - --hash=sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657 \ - --hash=sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262 \ - --hash=sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a \ - --hash=sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b \ - --hash=sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae \ - --hash=sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554 \ - --hash=sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05 \ - --hash=sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1 \ - --hash=sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622 \ - --hash=sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a \ - --hash=sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443 \ - --hash=sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98 \ - --hash=sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110 \ - --hash=sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308 \ - --hash=sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5 \ - --hash=sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef \ - --hash=sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab \ - --hash=sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909 \ - --hash=sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325 \ - --hash=sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979 \ - --hash=sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7 \ - --hash=sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7 \ - --hash=sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74 \ - --hash=sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499 \ - --hash=sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000 \ - --hash=sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a \ - --hash=sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913 \ - --hash=sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8 \ - --hash=sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d \ - --hash=sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb \ - --hash=sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236 \ - --hash=sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1 +numpy==2.4.4 \ + --hash=sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed \ + --hash=sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50 \ + --hash=sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959 \ + --hash=sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827 \ + --hash=sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd \ + --hash=sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233 \ + --hash=sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc \ + --hash=sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b \ + --hash=sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7 \ + --hash=sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e \ + --hash=sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a \ + --hash=sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d \ + --hash=sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3 \ + --hash=sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e \ + --hash=sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb \ + --hash=sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a \ + --hash=sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0 \ + --hash=sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e \ + --hash=sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113 \ + --hash=sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103 \ + --hash=sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93 \ + --hash=sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af \ + --hash=sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5 \ + --hash=sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7 \ + --hash=sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392 \ + --hash=sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c \ + --hash=sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4 \ + --hash=sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40 \ + --hash=sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf \ + --hash=sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44 \ + --hash=sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b \ + --hash=sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5 \ + --hash=sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e \ + --hash=sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74 \ + --hash=sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0 \ + --hash=sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e \ + --hash=sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec \ + --hash=sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015 \ + --hash=sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d \ + --hash=sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d \ + --hash=sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842 \ + --hash=sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150 \ + --hash=sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8 \ + --hash=sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a \ + --hash=sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed \ + --hash=sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f \ + --hash=sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008 \ + --hash=sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e \ + --hash=sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0 \ + --hash=sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e \ + --hash=sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f \ + --hash=sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a \ + --hash=sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40 \ + --hash=sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7 \ + --hash=sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83 \ + --hash=sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d \ + --hash=sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c \ + --hash=sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871 \ + --hash=sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502 \ + --hash=sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252 \ + --hash=sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8 \ + --hash=sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115 \ + --hash=sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f \ + --hash=sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e \ + --hash=sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d \ + --hash=sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0 \ + --hash=sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119 \ + --hash=sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e \ + --hash=sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db \ + --hash=sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121 \ + --hash=sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d \ + --hash=sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e # via # ocotilloapi # pandas # pandas-stubs # rasterio # shapely -opentelemetry-api==1.39.1 \ - --hash=sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950 \ - --hash=sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c +opentelemetry-api==1.40.0 \ + --hash=sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f \ + --hash=sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9 # via # opentelemetry-sdk # opentelemetry-semantic-conventions -opentelemetry-sdk==1.39.1 \ - --hash=sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c \ - --hash=sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6 +opentelemetry-sdk==1.40.0 \ + --hash=sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2 \ + --hash=sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1 # via apitally -opentelemetry-semantic-conventions==0.60b1 \ - --hash=sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953 \ - --hash=sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb +opentelemetry-semantic-conventions==0.61b0 \ + --hash=sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a \ + --hash=sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2 # via opentelemetry-sdk -packaging==25.0 \ - --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ - --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f +packaging==26.0 \ + --hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \ + --hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529 # via # geoalchemy2 # gunicorn @@ -922,63 +1296,106 @@ pg8000==1.31.5 \ --hash=sha256:0af2c1926b153307639868d2ee5cef6cd3a7d07448e12736989b10e1d491e201 \ --hash=sha256:46ebb03be52b7a77c03c725c79da2ca281d6e8f59577ca66b17c9009618cae78 # via ocotilloapi -phonenumbers==9.0.24 \ - --hash=sha256:97c38e4b5b8af992c75de01bd9c0f84e61701a9c900fd84f49744714910a4dc3 \ - --hash=sha256:fa86ab7112ef8b286a811392311bd76bbbae7d1d271c2ed26cf73f2e9fa4d3c6 +phonenumbers==9.0.26 \ + --hash=sha256:9e582c827f0f5503cddeebef80099475a52ffa761551d8384099c7ec71298cbf \ + --hash=sha256:ff473da5712965b6c7f7a31cbff8255864df694eb48243771133ecb761e807c1 # via ocotilloapi -pillow==11.3.0 \ - --hash=sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2 \ - --hash=sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214 \ - --hash=sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59 \ - --hash=sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50 \ - --hash=sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632 \ - --hash=sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a \ - --hash=sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51 \ - --hash=sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced \ - --hash=sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12 \ - --hash=sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8 \ - --hash=sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6 \ - --hash=sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580 \ - --hash=sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd \ - --hash=sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8 \ - --hash=sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673 \ - --hash=sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788 \ - --hash=sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e \ - --hash=sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8 \ - --hash=sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523 \ - --hash=sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477 \ - --hash=sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027 \ - --hash=sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b \ - --hash=sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e \ - --hash=sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b \ - --hash=sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae \ - --hash=sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d \ - --hash=sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f \ - --hash=sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874 \ - --hash=sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa \ - --hash=sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd \ - --hash=sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c \ - --hash=sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31 \ - --hash=sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e \ - --hash=sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db \ - --hash=sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77 \ - --hash=sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a \ - --hash=sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b \ - --hash=sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635 \ - --hash=sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3 \ - --hash=sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe \ - --hash=sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805 \ - --hash=sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36 \ - --hash=sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12 \ - --hash=sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c \ - --hash=sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6 \ - --hash=sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1 \ - --hash=sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653 \ - --hash=sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c +pillow==12.1.1 \ + --hash=sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9 \ + --hash=sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da \ + --hash=sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f \ + --hash=sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642 \ + --hash=sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713 \ + --hash=sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850 \ + --hash=sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9 \ + --hash=sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0 \ + --hash=sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9 \ + --hash=sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8 \ + --hash=sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6 \ + --hash=sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd \ + --hash=sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5 \ + --hash=sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c \ + --hash=sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35 \ + --hash=sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1 \ + --hash=sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff \ + --hash=sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38 \ + --hash=sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4 \ + --hash=sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af \ + --hash=sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60 \ + --hash=sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986 \ + --hash=sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13 \ + --hash=sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717 \ + --hash=sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e \ + --hash=sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b \ + --hash=sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15 \ + --hash=sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a \ + --hash=sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb \ + --hash=sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d \ + --hash=sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b \ + --hash=sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e \ + --hash=sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a \ + --hash=sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f \ + --hash=sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a \ + --hash=sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce \ + --hash=sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc \ + --hash=sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f \ + --hash=sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586 \ + --hash=sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f \ + --hash=sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9 \ + --hash=sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8 \ + --hash=sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40 \ + --hash=sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60 \ + --hash=sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c \ + --hash=sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0 \ + --hash=sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334 \ + --hash=sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af \ + --hash=sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735 \ + --hash=sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524 \ + --hash=sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf \ + --hash=sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b \ + --hash=sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2 \ + --hash=sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9 \ + --hash=sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7 \ + --hash=sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e \ + --hash=sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4 \ + --hash=sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4 \ + --hash=sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b \ + --hash=sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397 \ + --hash=sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c \ + --hash=sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e \ + --hash=sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029 \ + --hash=sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3 \ + --hash=sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052 \ + --hash=sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984 \ + --hash=sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293 \ + --hash=sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523 \ + --hash=sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f \ + --hash=sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b \ + --hash=sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80 \ + --hash=sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f \ + --hash=sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79 \ + --hash=sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23 \ + --hash=sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8 \ + --hash=sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e \ + --hash=sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3 \ + --hash=sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e \ + --hash=sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36 \ + --hash=sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f \ + --hash=sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5 \ + --hash=sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f \ + --hash=sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6 \ + --hash=sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32 \ + --hash=sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20 \ + --hash=sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202 \ + --hash=sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0 \ + --hash=sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3 \ + --hash=sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563 \ + --hash=sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090 \ + --hash=sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289 # via ocotilloapi -platformdirs==4.3.8 \ - --hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \ - --hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4 +platformdirs==4.9.4 \ + --hash=sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934 \ + --hash=sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868 # via virtualenv pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ @@ -1058,9 +1475,9 @@ propcache==0.4.1 \ # aiohttp # ocotilloapi # yarl -proto-plus==1.27.1 \ - --hash=sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147 \ - --hash=sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc +proto-plus==1.27.2 \ + --hash=sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718 \ + --hash=sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24 # via # google-api-core # ocotilloapi @@ -1126,9 +1543,9 @@ psycopg2-binary==2.9.11 \ --hash=sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa \ --hash=sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747 # via ocotilloapi -pyasn1==0.6.2 \ - --hash=sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf \ - --hash=sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b +pyasn1==0.6.3 \ + --hash=sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf \ + --hash=sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde # via # ocotilloapi # pyasn1-modules @@ -1140,9 +1557,9 @@ pyasn1-modules==0.4.2 \ # via # google-auth # ocotilloapi -pycparser==2.23 \ - --hash=sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2 \ - --hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934 +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 # via # cffi # ocotilloapi @@ -1215,16 +1632,16 @@ pygeoif==1.6.0 \ # via # pygeoapi # pygeofilter -pygments==2.19.2 \ - --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ - --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 # via # ocotilloapi # pytest # rich -pyjwt==2.11.0 \ - --hash=sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623 \ - --hash=sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469 +pyjwt==2.12.1 \ + --hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \ + --hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b # via ocotilloapi pyparsing==3.3.2 \ --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \ @@ -1275,9 +1692,9 @@ pyshp==2.3.1 \ --hash=sha256:4caec82fd8dd096feba8217858068bacb2a3b5950f43c048c6dc32a3489d5af1 \ --hash=sha256:67024c0ccdc352ba5db777c4e968483782dfa78f8e200672a90d2d30fd8b7b49 # via ocotilloapi -pytest==9.0.2 \ - --hash=sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b \ - --hash=sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11 +pytest==9.0.3 \ + --hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \ + --hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c # via # ocotilloapi # pytest-cov @@ -1294,9 +1711,9 @@ python-dateutil==2.9.0.post0 \ # pandas # pg8000 # pygeoapi -python-dotenv==1.2.1 \ - --hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \ - --hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61 +python-dotenv==1.2.2 \ + --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ + --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 # via dotenv python-jose==3.5.0 \ --hash=sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771 \ @@ -1316,17 +1733,80 @@ pytz==2025.2 \ # ocotilloapi # pandas # pygeoapi -pyyaml==6.0.2 \ - --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \ - --hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \ - --hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \ - --hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \ - --hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \ - --hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \ - --hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \ - --hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \ - --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \ - --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 # via # pre-commit # pygeoapi @@ -1363,85 +1843,134 @@ referencing==0.37.0 \ # via # jsonschema # jsonschema-specifications -regex==2026.2.19 \ - --hash=sha256:015088b8558502f1f0bccd58754835aa154a7a5b0bd9d4c9b7b96ff4ae9ba876 \ - --hash=sha256:02b9e1b8a7ebe2807cd7bbdf662510c8e43053a23262b9f46ad4fc2dfc9d204e \ - --hash=sha256:03d191a9bcf94d31af56d2575210cb0d0c6a054dbcad2ea9e00aa4c42903b919 \ - --hash=sha256:0d0e72703c60d68b18b27cde7cdb65ed2570ae29fb37231aa3076bfb6b1d1c13 \ - --hash=sha256:11c138febb40546ff9e026dbbc41dc9fb8b29e61013fa5848ccfe045f5b23b83 \ - --hash=sha256:127ea69273485348a126ebbf3d6052604d3c7da284f797bba781f364c0947d47 \ - --hash=sha256:17648e1a88e72d88641b12635e70e6c71c5136ba14edba29bf8fc6834005a265 \ - --hash=sha256:1e7a08622f7d51d7a068f7e4052a38739c412a3e74f55817073d2e2418149619 \ - --hash=sha256:2905ff4a97fad42f2d0834d8b1ea3c2f856ec209837e458d71a061a7d05f9f01 \ - --hash=sha256:294c0fb2e87c6bcc5f577c8f609210f5700b993151913352ed6c6af42f30f95f \ - --hash=sha256:2c1693ca6f444d554aa246b592355b5cec030ace5a2729eae1b04ab6e853e768 \ - --hash=sha256:2f914ae8c804c8a8a562fe216100bc156bfb51338c1f8d55fe32cf407774359a \ - --hash=sha256:2fedd459c791da24914ecc474feecd94cf7845efb262ac3134fe27cbd7eda799 \ - --hash=sha256:311fcccb76af31be4c588d5a17f8f1a059ae8f4b097192896ebffc95612f223a \ - --hash=sha256:3aa0944f1dc6e92f91f3b306ba7f851e1009398c84bfd370633182ee4fc26a64 \ - --hash=sha256:4071209fd4376ab5ceec72ad3507e9d3517c59e38a889079b98916477a871868 \ - --hash=sha256:43cdde87006271be6963896ed816733b10967baaf0e271d529c82e93da66675b \ - --hash=sha256:46e69a4bf552e30e74a8aa73f473c87efcb7f6e8c8ece60d9fd7bf13d5c86f02 \ - --hash=sha256:4a02faea614e7fdd6ba8b3bec6c8e79529d356b100381cec76e638f45d12ca04 \ - --hash=sha256:50f1ee9488dd7a9fda850ec7c68cad7a32fa49fd19733f5403a3f92b451dcf73 \ - --hash=sha256:516ee067c6c721d0d0bfb80a2004edbd060fffd07e456d4e1669e38fe82f922e \ - --hash=sha256:5390b130cce14a7d1db226a3896273b7b35be10af35e69f1cca843b6e5d2bb2d \ - --hash=sha256:5a8f28dd32a4ce9c41758d43b5b9115c1c497b4b1f50c457602c1d571fa98ce1 \ - --hash=sha256:5e3a31e94d10e52a896adaa3adf3621bd526ad2b45b8c2d23d1bbe74c7423007 \ - --hash=sha256:5e56c669535ac59cbf96ca1ece0ef26cb66809990cda4fa45e1e32c3b146599e \ - --hash=sha256:5ec1d7c080832fdd4e150c6f5621fe674c70c63b3ae5a4454cebd7796263b175 \ - --hash=sha256:6380f29ff212ec922b6efb56100c089251940e0526a0d05aa7c2d9b571ddf2fe \ - --hash=sha256:64128549b600987e0f335c2365879895f860a9161f283b14207c800a6ed623d3 \ - --hash=sha256:654dc41a5ba9b8cc8432b3f1aa8906d8b45f3e9502442a07c2f27f6c63f85db5 \ - --hash=sha256:655f553a1fa3ab8a7fd570eca793408b8d26a80bfd89ed24d116baaf13a38969 \ - --hash=sha256:6c8fb3b19652e425ff24169dad3ee07f99afa7996caa9dfbb3a9106cd726f49a \ - --hash=sha256:6fb8cb09b10e38f3ae17cc6dc04a1df77762bd0351b6ba9041438e7cc85ec310 \ - --hash=sha256:7187fdee1be0896c1499a991e9bf7c78e4b56b7863e7405d7bb687888ac10c4b \ - --hash=sha256:74ff212aa61532246bb3036b3dfea62233414b0154b8bc3676975da78383cac3 \ - --hash=sha256:77cfd6b5e7c4e8bf7a39d243ea05882acf5e3c7002b0ef4756de6606893b0ecd \ - --hash=sha256:790dbf87b0361606cb0d79b393c3e8f4436a14ee56568a7463014565d97da02a \ - --hash=sha256:80caaa1ddcc942ec7be18427354f9d58a79cee82dea2a6b3d4fd83302e1240d7 \ - --hash=sha256:8457c1bc10ee9b29cdfd897ccda41dce6bde0e9abd514bcfef7bcd05e254d411 \ - --hash=sha256:8497421099b981f67c99eba4154cf0dfd8e47159431427a11cfb6487f7791d9e \ - --hash=sha256:8abe671cf0f15c26b1ad389bf4043b068ce7d3b1c5d9313e12895f57d6738555 \ - --hash=sha256:8df08decd339e8b3f6a2eb5c05c687fe9d963ae91f352bc57beb05f5b2ac6879 \ - --hash=sha256:8e6e77cd92216eb489e21e5652a11b186afe9bdefca8a2db739fd6b205a9e0a4 \ - --hash=sha256:8edda06079bd770f7f0cf7f3bba1a0b447b96b4a543c91fe0c142d034c166161 \ - --hash=sha256:93d881cab5afdc41a005dba1524a40947d6f7a525057aa64aaf16065cf62faa9 \ - --hash=sha256:997862c619994c4a356cb7c3592502cbd50c2ab98da5f61c5c871f10f22de7e5 \ - --hash=sha256:9cbc69eae834afbf634f7c902fc72ff3e993f1c699156dd1af1adab5d06b7fe7 \ - --hash=sha256:9e6693b8567a59459b5dda19104c4a4dbbd4a1c78833eacc758796f2cfef1854 \ - --hash=sha256:9fff45852160960f29e184ec8a5be5ab4063cfd0b168d439d1fc4ac3744bf29e \ - --hash=sha256:a09ae430e94c049dc6957f6baa35ee3418a3a77f3c12b6e02883bd80a2b679b0 \ - --hash=sha256:a178df8ec03011153fbcd2c70cb961bc98cbbd9694b28f706c318bee8927c3db \ - --hash=sha256:ab780092b1424d13200aa5a62996e95f65ee3db8509be366437439cdc0af1a9f \ - --hash=sha256:b5100acb20648d9efd3f4e7e91f51187f95f22a741dcd719548a6cf4e1b34b3f \ - --hash=sha256:b9ab8dec42afefa6314ea9b31b188259ffdd93f433d77cad454cd0b8d235ce1c \ - --hash=sha256:bcf57d30659996ee5c7937999874504c11b5a068edc9515e6a59221cc2744dd1 \ - --hash=sha256:c0761d7ae8d65773e01515ebb0b304df1bf37a0a79546caad9cbe79a42c12af7 \ - --hash=sha256:c0924c64b082d4512b923ac016d6e1dcf647a3560b8a4c7e55cbbd13656cb4ed \ - --hash=sha256:c13228fbecb03eadbfd8f521732c5fda09ef761af02e920a3148e18ad0e09968 \ - --hash=sha256:c227f2922153ee42bbeb355fd6d009f8c81d9d7bdd666e2276ce41f53ed9a743 \ - --hash=sha256:c7e121a918bbee3f12ac300ce0a0d2f2c979cf208fb071ed8df5a6323281915c \ - --hash=sha256:cce8027010d1ffa3eb89a0b19621cdc78ae548ea2b49fea1f7bfb3ea77064c2b \ - --hash=sha256:d00c95a2b6bfeb3ea1cb68d1751b1dfce2b05adc2a72c488d77a780db06ab867 \ - --hash=sha256:d793c5b4d2b4c668524cd1651404cfc798d40694c759aec997e196fe9729ec60 \ - --hash=sha256:d96162140bb819814428800934c7b71b7bffe81fb6da2d6abc1dcca31741eca3 \ - --hash=sha256:e581f75d5c0b15669139ca1c2d3e23a65bb90e3c06ba9d9ea194c377c726a904 \ - --hash=sha256:ea8dfc99689240e61fb21b5fc2828f68b90abf7777d057b62d3166b7c1543c4c +regex==2026.3.32 \ + --hash=sha256:03c2ebd15ff51e7b13bb3dc28dd5ac18cd39e59ebb40430b14ae1a19e833cff1 \ + --hash=sha256:09e26cad1544d856da85881ad292797289e4406338afe98163f3db9f7fac816c \ + --hash=sha256:0cec365d44835b043d7b3266487797639d07d621bec9dc0ea224b00775797cc1 \ + --hash=sha256:0d7855f5e59fcf91d0c9f4a51dc5d8847813832a2230c3e8e35912ccf20baaa2 \ + --hash=sha256:0f21ae18dfd15752cdd98d03cbd7a3640be826bfd58482a93f730dbd24d7b9fb \ + --hash=sha256:10fb2aaae1aaadf7d43c9f3c2450404253697bf8b9ce360bd5418d1d16292298 \ + --hash=sha256:110ba4920721374d16c4c8ea7ce27b09546d43e16aea1d7f43681b5b8f80ba61 \ + --hash=sha256:12917c6c6813ffcdfb11680a04e4d63c5532b88cf089f844721c5f41f41a63ad \ + --hash=sha256:18eb45f711e942c27dbed4109830bd070d8d618e008d0db39705f3f57070a4c6 \ + --hash=sha256:1a6ac1ed758902e664e0d95c1ee5991aa6fb355423f378ed184c6ec47a1ec0e9 \ + --hash=sha256:1ca02ff0ef33e9d8276a1fcd6d90ff6ea055a32c9149c0050b5b67e26c6d2c51 \ + --hash=sha256:1cb22fa9ee6a0acb22fc9aecce5f9995fe4d2426ed849357d499d62608fbd7f9 \ + --hash=sha256:1e0f6648fd48f4c73d801c55ab976cd602e2da87de99c07bff005b131f269c6a \ + --hash=sha256:245667ad430745bae6a1e41081872d25819d86fbd9e0eec485ba00d9f78ad43d \ + --hash=sha256:2820d2231885e97aff0fcf230a19ebd5d2b5b8a1ba338c20deb34f16db1c7897 \ + --hash=sha256:2c8d402ea3dfe674288fe3962016affd33b5b27213d2b5db1823ffa4de524c57 \ + --hash=sha256:2dcca2bceb823c9cc610e57b86a265d7ffc30e9fe98548c609eba8bd3c0c2488 \ + --hash=sha256:2ffbadc647325dd4e3118269bda93ded1eb5f5b0c3b7ba79a3da9fbd04f248e9 \ + --hash=sha256:34c905a721ddee0f84c99e3e3b59dd4a5564a6fe338222bc89dd4d4df166115c \ + --hash=sha256:3c054e39a9f85a3d76c62a1d50c626c5e9306964eaa675c53f61ff7ec1204bbb \ + --hash=sha256:3c0bbfbd38506e1ea96a85da6782577f06239cb9fcf9696f1ea537c980c0680b \ + --hash=sha256:3e221b615f83b15887636fcb90ed21f1a19541366f8b7ba14ba1ad8304f4ded4 \ + --hash=sha256:3ea568832eca219c2be1721afa073c1c9eb8f98a9733fdedd0a9747639fc22a5 \ + --hash=sha256:3f5747501b69299c6b0b047853771e4ed390510bada68cb16da9c9c2078343f7 \ + --hash=sha256:462a041d2160090553572f6bb0be417ab9bb912a08de54cb692829c871ee88c1 \ + --hash=sha256:4bc32b4dbdb4f9f300cf9f38f8ea2ce9511a068ffaa45ac1373ee7a943f1d810 \ + --hash=sha256:4d082be64e51671dd5ee1c208c92da2ddda0f2f20d8ef387e57634f7e97b6aae \ + --hash=sha256:4f9ae4755fa90f1dc2d0d393d572ebc134c0fe30fcfc0ab7e67c1db15f192041 \ + --hash=sha256:51a93452034d671b0e21b883d48ea66c5d6a05620ee16a9d3f229e828568f3f0 \ + --hash=sha256:51fb7e26f91f9091fd8ec6a946f99b15d3bc3667cb5ddc73dd6cb2222dd4a1cc \ + --hash=sha256:5336b1506142eb0f23c96fb4a34b37c4fefd4fed2a7042069f3c8058efe17855 \ + --hash=sha256:567b57eb987547a23306444e4f6f85d4314f83e65c71d320d898aa7550550443 \ + --hash=sha256:5aa78c857c1731bdd9863923ffadc816d823edf475c7db6d230c28b53b7bdb5e \ + --hash=sha256:5bf2f3c2c5bd8360d335c7dcd4a9006cf1dabae063ee2558ee1b07bbc8a20d88 \ + --hash=sha256:5c35d097f509cf7e40d20d5bee548d35d6049b36eb9965e8d43e4659923405b9 \ + --hash=sha256:5d86e3fb08c94f084a625c8dc2132a79a3a111c8bf6e2bc59351fa61753c2f6e \ + --hash=sha256:6062c4ef581a3e9e503dccf4e1b7f2d33fdc1c13ad510b287741ac73bc4c6b27 \ + --hash=sha256:6128dd0793a87287ea1d8bf16b4250dd96316c464ee15953d5b98875a284d41e \ + --hash=sha256:631f7d95c83f42bccfe18946a38ad27ff6b6717fb4807e60cf24860b5eb277fc \ + --hash=sha256:66a5083c3ffe5a5a95f8281ea47a88072d4f24001d562d1d9d28d4cdc005fec5 \ + --hash=sha256:66d3126afe7eac41759cd5f0b3b246598086e88e70527c0d68c9e615b81771c4 \ + --hash=sha256:67015a8162d413af9e3309d9a24e385816666fbf09e48e3ec43342c8536f7df6 \ + --hash=sha256:6980ceb5c1049d4878632f08ba0bf7234c30e741b0dc9081da0f86eca13189d3 \ + --hash=sha256:69a847a6ffaa86e8af7b9e7037606e05a6f663deec516ad851e8e05d9908d16a \ + --hash=sha256:6ada7bd5bb6511d12177a7b00416ce55caee49fbf8c268f26b909497b534cacb \ + --hash=sha256:70c634e39c5cda0da05c93d6747fdc957599f7743543662b6dbabdd8d3ba8a96 \ + --hash=sha256:7cdd508664430dd51b8888deb6c5b416d8de046b2e11837254378d31febe4a98 \ + --hash=sha256:844d88509c968dd44b30daeefac72b038b1bf31ac372d5106358ab01d393c48b \ + --hash=sha256:847087abe98b3c1ebf1eb49d6ef320dbba75a83ee4f83c94704580f1df007dd4 \ + --hash=sha256:85c9b0c131427470a6423baa0a9330be6fd8c3630cc3ee6fdee03360724cbec5 \ + --hash=sha256:879ae91f2928a13f01a55cfa168acedd2b02b11b4cd8b5bb9223e8cde777ca52 \ + --hash=sha256:887a9fa74418d74d645281ee0edcf60694053bd1bc2ebc49eb5e66bfffc6d107 \ + --hash=sha256:88ebc0783907468f17fca3d7821b30f9c21865a721144eb498cb0ff99a67bcac \ + --hash=sha256:89e50667e7e8c0e7903e4d644a2764fffe9a3a5d6578f72ab7a7b4205bf204b7 \ + --hash=sha256:8a4a3189a99ecdd1c13f42513ab3fc7fa8311b38ba7596dd98537acb8cd9acc3 \ + --hash=sha256:8aaf8ee8f34b677f90742ca089b9c83d64bdc410528767273c816a863ed57327 \ + --hash=sha256:8e4c8fa46aad1a11ae2f8fcd1c90b9d55e18925829ac0d98c5bb107f93351745 \ + --hash=sha256:8fc918cd003ba0d066bf0003deb05a259baaaab4dc9bd4f1207bbbe64224857a \ + --hash=sha256:8fe14e24124ef41220e5992a0f09432f890037df6f93fd3d6b7a0feff2db16b2 \ + --hash=sha256:918db4e34a7ef3d0beee913fa54b34231cc3424676f1c19bdb85f01828d3cd37 \ + --hash=sha256:987cdfcfb97a249abc3601ad53c7de5c370529f1981e4c8c46793e4a1e1bfe8e \ + --hash=sha256:9b9118a78e031a2e4709cd2fcc3028432e89b718db70073a8da574c249b5b249 \ + --hash=sha256:9cf7036dfa2370ccc8651521fcbb40391974841119e9982fa312b552929e6c85 \ + --hash=sha256:a094e9dcafedfb9d333db5cf880304946683f43a6582bb86688f123335122929 \ + --hash=sha256:a416ee898ecbc5d8b283223b4cf4d560f93244f6f7615c1bd67359744b00c166 \ + --hash=sha256:a5d88fa37ba5e8a80ca8d956b9ea03805cfa460223ac94b7d4854ee5e30f3173 \ + --hash=sha256:ace48c5e157c1e58b7de633c5e257285ce85e567ac500c833349c363b3df69d4 \ + --hash=sha256:ad5c53f2e8fcae9144009435ebe3d9832003508cf8935c04542a1b3b8deefa15 \ + --hash=sha256:ad8d372587e659940568afd009afeb72be939c769c552c9b28773d0337251391 \ + --hash=sha256:b193ed199848aa96618cd5959c1582a0bf23cd698b0b900cb0ffe81b02c8659c \ + --hash=sha256:b2e9c2ea2e93223579308263f359eab8837dc340530b860cb59b713651889f14 \ + --hash=sha256:b3aa21bad31db904e0b9055e12c8282df62d43169c4a9d2929407060066ebc74 \ + --hash=sha256:b565f25171e04d4fad950d1fa837133e3af6ea6f509d96166eed745eb0cf63bc \ + --hash=sha256:b56993a7aeb4140c4770f4f7965c9e5af4f024457d06e23c01b0d47501cb18ed \ + --hash=sha256:b6acb765e7c1f2fa08ac9057a33595e26104d7d67046becae184a8f100932dd9 \ + --hash=sha256:b6f366a5ef66a2df4d9e68035cfe9f0eb8473cdfb922c37fac1d169b468607b0 \ + --hash=sha256:b7836aa13721dbdef658aebd11f60d00de633a95726521860fe1f6be75fa225a \ + --hash=sha256:b8fca73e16c49dd972ce3a88278dfa5b93bf91ddef332a46e9443abe21ca2f7c \ + --hash=sha256:b953d9d496d19786f4d46e6ba4b386c6e493e81e40f9c5392332458183b0599d \ + --hash=sha256:bbc458a292aee57d572075f22c035fa32969cdb7987d454e3e34d45a40a0a8b4 \ + --hash=sha256:c1cecea3e477af105f32ef2119b8d895f297492e41d317e60d474bc4bffd62ff \ + --hash=sha256:c1d7fa44aece1fa02b8927441614c96520253a5cad6a96994e3a81e060feed55 \ + --hash=sha256:c1ed17104d1be7f807fdec35ec99777168dd793a09510d753f8710590ba54cdd \ + --hash=sha256:c3c6f6b027d10f84bfe65049028892b5740878edd9eae5fea0d1710b09b1d257 \ + --hash=sha256:c5e0fdb5744caf1036dec5510f543164f2144cb64932251f6dfd42fa872b7f9c \ + --hash=sha256:c60f1de066eb5a0fd8ee5974de4194bb1c2e7692941458807162ffbc39887303 \ + --hash=sha256:c6d9c6e783b348f719b6118bb3f187b2e138e3112576c9679eb458cc8b2e164b \ + --hash=sha256:c940e00e8d3d10932c929d4b8657c2ea47d2560f31874c3e174c0d3488e8b865 \ + --hash=sha256:c9f261ad3cd97257dc1d9355bfbaa7dd703e06574bffa0fa8fe1e31da915ee38 \ + --hash=sha256:d21a07edddb3e0ca12a8b8712abc8452481c3d3db19ae87fc94e9842d005964b \ + --hash=sha256:d363660f9ef8c734495598d2f3e527fb41f745c73159dc0d743402f049fb6836 \ + --hash=sha256:d478a2ca902b6ef28ffc9521e5f0f728d036abe35c0b250ee8ae78cfe7c5e44e \ + --hash=sha256:d571f0b2eec3513734ea31a16ce0f7840c0b85a98e7edfa0e328ed144f9ef78f \ + --hash=sha256:d6b39a2cc5625bbc4fda18919a891eab9aab934eecf83660a90ce20c53621a9a \ + --hash=sha256:d76d62909bfb14521c3f7cfd5b94c0c75ec94b0a11f647d2f604998962ec7b6c \ + --hash=sha256:dab4178a0bc1ef13178832b12db7bc7f562e8f028b2b5be186e370090dc50652 \ + --hash=sha256:db976be51375bca900e008941639448d148c655c9545071965d0571ecc04f5d0 \ + --hash=sha256:ded4fc0edf3de792850cb8b04bbf3c5bd725eeaf9df4c27aad510f6eed9c4e19 \ + --hash=sha256:e006ea703d5c0f3d112b51ba18af73b58209b954acfe3d8da42eacc9a00e4be6 \ + --hash=sha256:e3e5d1802cba785210a4a800e63fcee7a228649a880f3bf7f2aadccb151a834b \ + --hash=sha256:e480d3dac06c89bc2e0fd87524cc38c546ac8b4a38177650745e64acbbcfdeba \ + --hash=sha256:e50af656c15e2723eeb7279c0837e07accc594b95ec18b86821a4d44b51b24bf \ + --hash=sha256:e83ce8008b48762be296f1401f19afd9ea29f3d035d1974e0cecb74e9afbd1df \ + --hash=sha256:ed3b8281c5d0944d939c82db4ec2300409dd69ee087f7a75a94f2e301e855fb4 \ + --hash=sha256:ef250a3f5e93182193f5c927c5e9575b2cb14b80d03e258bc0b89cc5de076b60 \ + --hash=sha256:f1574566457161678297a116fa5d1556c5a4159d64c5ff7c760e7c564bf66f16 \ + --hash=sha256:f26262900edd16272b6360014495e8d68379c6c6e95983f9b7b322dc928a1194 \ + --hash=sha256:f28eac18a8733a124444643a66ac96fef2c0ad65f50034e0a043b90333dc677f \ + --hash=sha256:f54840bea73541652f1170dc63402a5b776fc851ad36a842da9e5163c1f504a0 \ + --hash=sha256:f785f44a44702dea89b28bce5bc82552490694ce4e144e21a4f0545e364d2150 \ + --hash=sha256:f7cc00089b4c21847852c0ad76fb3680f9833b855a0d30bcec94211c435bff6b \ + --hash=sha256:f95bd07f301135771559101c060f558e2cf896c7df00bec050ca7f93bf11585a \ + --hash=sha256:fc8ced733d6cd9af5e412f256a32f7c61cd2d7371280a65c689939ac4572499f \ + --hash=sha256:fd03e38068faeef937cc6761a250a4aaa015564bd0d61481fefcf15586d31825 # via dateparser -requests==2.32.5 \ - --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ - --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf +requests==2.33.1 \ + --hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \ + --hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a # via # cloud-sql-python-connector # google-api-core # google-cloud-storage # ocotilloapi # pygeoapi -rich==14.3.2 \ - --hash=sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69 \ - --hash=sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8 +rich==14.3.3 \ + --hash=sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d \ + --hash=sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b # via typer rpds-py==0.30.0 \ --hash=sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136 \ @@ -1519,9 +2048,9 @@ scramp==1.4.8 \ # via # ocotilloapi # pg8000 -sentry-sdk==2.53.0 \ - --hash=sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899 \ - --hash=sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77 +sentry-sdk==2.56.0 \ + --hash=sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02 \ + --hash=sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168 # via ocotilloapi shapely==2.1.2 \ --hash=sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9 \ @@ -1575,31 +2104,70 @@ sniffio==1.3.1 \ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc # via ocotilloapi -sqlalchemy==2.0.46 \ - --hash=sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366 \ - --hash=sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b \ - --hash=sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863 \ - --hash=sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa \ - --hash=sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf \ - --hash=sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada \ - --hash=sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad \ - --hash=sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908 \ - --hash=sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef \ - --hash=sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330 \ - --hash=sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f \ - --hash=sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee \ - --hash=sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e \ - --hash=sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00 \ - --hash=sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764 \ - --hash=sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d \ - --hash=sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10 \ - --hash=sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2 \ - --hash=sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b \ - --hash=sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7 \ - --hash=sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447 \ - --hash=sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e \ - --hash=sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e \ - --hash=sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede +sqlalchemy==2.0.48 \ + --hash=sha256:01f6bbd4308b23240cf7d3ef117557c8fd097ec9549d5d8a52977544e35b40ad \ + --hash=sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e \ + --hash=sha256:10853a53a4a00417a00913d270dddda75815fcb80675874285f41051c094d7dd \ + --hash=sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6 \ + --hash=sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0 \ + --hash=sha256:1a89ce07ad2d4b8cfc30bd5889ec40613e028ed80ef47da7d9dd2ce969ad30e0 \ + --hash=sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc \ + --hash=sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b \ + --hash=sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f \ + --hash=sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0 \ + --hash=sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894 \ + --hash=sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b \ + --hash=sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8 \ + --hash=sha256:3713e21ea67bca727eecd4a24bf68bcd414c403faae4989442be60994301ded0 \ + --hash=sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131 \ + --hash=sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b \ + --hash=sha256:4599a95f9430ae0de82b52ff0d27304fe898c17cb5f4099f7438a51b9998ac77 \ + --hash=sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f \ + --hash=sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb \ + --hash=sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9 \ + --hash=sha256:583849c743e0e3c9bb7446f5b5addeacedc168d657a69b418063dfdb2d90081c \ + --hash=sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241 \ + --hash=sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658 \ + --hash=sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7 \ + --hash=sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a \ + --hash=sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae \ + --hash=sha256:6bb85c546591569558571aa1b06aba711b26ae62f111e15e56136d69920e1616 \ + --hash=sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7 \ + --hash=sha256:7001dc9d5f6bb4deb756d5928eaefe1930f6f4179da3924cbd95ee0e9f4dce89 \ + --hash=sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3 \ + --hash=sha256:7c998f2ace8bf76b453b75dbcca500d4f4b9dd3908c13e89b86289b37784848b \ + --hash=sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0 \ + --hash=sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2 \ + --hash=sha256:82745b03b4043e04600a6b665cb98697c4339b24e34d74b0a2ac0a2488b6f94d \ + --hash=sha256:841a94c66577661c1f088ac958cd767d7c9bf507698f45afffe7a4017049de76 \ + --hash=sha256:858e433f12b0e5b3ed2f8da917433b634f4937d0e8793e5cb33c54a1a01df565 \ + --hash=sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99 \ + --hash=sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485 \ + --hash=sha256:9c7d0a77e36b5f4b01ca398482230ab792061d243d715299b44a0b55c89fe617 \ + --hash=sha256:a5b429eb84339f9f05e06083f119ad814e6d85e27ecbdf9c551dfdbb128eaf8a \ + --hash=sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096 \ + --hash=sha256:a6b764fb312bd35e47797ad2e63f0d323792837a6ac785a4ca967019357d2bc7 \ + --hash=sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed \ + --hash=sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f \ + --hash=sha256:b8fc3454b4f3bd0a368001d0e968852dad45a873f8b4babd41bc302ec851a099 \ + --hash=sha256:bcb8ebbf2e2c36cfe01a94f2438012c6a9d494cf80f129d9753bcdf33bfc35a6 \ + --hash=sha256:d404dc897ce10e565d647795861762aa2d06ca3f4a728c5e9a835096c7059018 \ + --hash=sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2 \ + --hash=sha256:d64177f443594c8697369c10e4bbcac70ef558e0f7921a1de7e4a3d1734bcf67 \ + --hash=sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933 \ + --hash=sha256:d8fcccbbc0c13c13702c471da398b8cd72ba740dca5859f148ae8e0e8e0d3e7e \ + --hash=sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b \ + --hash=sha256:e214d546c8ecb5fc22d6e6011746082abf13a9cf46eefb45769c7b31407c97b5 \ + --hash=sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd \ + --hash=sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79 \ + --hash=sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4 \ + --hash=sha256:e5e088bf43f6ee6fec7dbf1ef7ff7774a616c236b5c0cb3e00662dd71a56b571 \ + --hash=sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c \ + --hash=sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121 \ + --hash=sha256:f27f9da0a7d22b9f981108fd4b62f8b5743423388915a563e651c20d06c1f457 \ + --hash=sha256:f8649a14caa5f8a243628b1d61cf530ad9ae4578814ba726816adb1121fc493e \ + --hash=sha256:fac0fa4e4f55f118fd87177dacb1c6522fe39c28d498d259014020fec9164c29 \ + --hash=sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb # via # alembic # geoalchemy2 @@ -1638,9 +2206,9 @@ tinydb==4.8.2 \ --hash=sha256:f7dfc39b8d7fda7a1ca62a8dbb449ffd340a117c1206b68c50b1a481fb95181d \ --hash=sha256:f97030ee5cbc91eeadd1d7af07ab0e48ceb04aa63d4a983adbaca4cba16e86c3 # via pygeoapi -typer==0.23.1 \ - --hash=sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134 \ - --hash=sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e +typer==0.24.1 \ + --hash=sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e \ + --hash=sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45 # via ocotilloapi types-pytz==2025.2.0.20250809 \ --hash=sha256:222e32e6a29bb28871f8834e8785e3801f2dc4441c715cd2082b271eecbe21e5 \ @@ -1691,85 +2259,147 @@ utm==0.8.1 \ --hash=sha256:634d5b6221570ddc6a1e94afa5c51bae92bcead811ddc5c9bc0a20b847c2dafa \ --hash=sha256:e3d5e224082af138e40851dcaad08d7f99da1cc4b5c413a7de34eabee35f434a # via ocotilloapi -uvicorn==0.40.0 \ - --hash=sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea \ - --hash=sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee +uvicorn==0.42.0 \ + --hash=sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359 \ + --hash=sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775 # via ocotilloapi virtualenv==20.32.0 \ --hash=sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56 \ --hash=sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0 # via pre-commit -werkzeug==3.1.6 \ - --hash=sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25 \ - --hash=sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131 +werkzeug==3.1.7 \ + --hash=sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f \ + --hash=sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351 # via flask -yarl==1.22.0 \ - --hash=sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a \ - --hash=sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da \ - --hash=sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093 \ - --hash=sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79 \ - --hash=sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683 \ - --hash=sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2 \ - --hash=sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff \ - --hash=sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02 \ - --hash=sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03 \ - --hash=sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c \ - --hash=sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c \ - --hash=sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da \ - --hash=sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2 \ - --hash=sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0 \ - --hash=sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53 \ - --hash=sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138 \ - --hash=sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4 \ - --hash=sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d \ - --hash=sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f \ - --hash=sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1 \ - --hash=sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d \ - --hash=sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694 \ - --hash=sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3 \ - --hash=sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a \ - --hash=sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b \ - --hash=sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5 \ - --hash=sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f \ - --hash=sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df \ - --hash=sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b \ - --hash=sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b \ - --hash=sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2 \ - --hash=sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708 \ - --hash=sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10 \ - --hash=sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b \ - --hash=sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e \ - --hash=sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33 \ - --hash=sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590 \ - --hash=sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53 \ - --hash=sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f \ - --hash=sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1 \ - --hash=sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27 \ - --hash=sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273 \ - --hash=sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601 \ - --hash=sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784 \ - --hash=sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71 \ - --hash=sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b \ - --hash=sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a \ - --hash=sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c \ - --hash=sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face \ - --hash=sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d \ - --hash=sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e \ - --hash=sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9 \ - --hash=sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95 \ - --hash=sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf \ - --hash=sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca \ - --hash=sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62 \ - --hash=sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67 \ - --hash=sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529 \ - --hash=sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486 \ - --hash=sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a \ - --hash=sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d \ - --hash=sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b \ - --hash=sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e \ - --hash=sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8 \ - --hash=sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd \ - --hash=sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249 +yarl==1.23.0 \ + --hash=sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc \ + --hash=sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4 \ + --hash=sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85 \ + --hash=sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993 \ + --hash=sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222 \ + --hash=sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de \ + --hash=sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25 \ + --hash=sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e \ + --hash=sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2 \ + --hash=sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e \ + --hash=sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860 \ + --hash=sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957 \ + --hash=sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760 \ + --hash=sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52 \ + --hash=sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788 \ + --hash=sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912 \ + --hash=sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719 \ + --hash=sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035 \ + --hash=sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220 \ + --hash=sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412 \ + --hash=sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05 \ + --hash=sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41 \ + --hash=sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4 \ + --hash=sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4 \ + --hash=sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd \ + --hash=sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748 \ + --hash=sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a \ + --hash=sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4 \ + --hash=sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34 \ + --hash=sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069 \ + --hash=sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25 \ + --hash=sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2 \ + --hash=sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb \ + --hash=sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f \ + --hash=sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5 \ + --hash=sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8 \ + --hash=sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c \ + --hash=sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512 \ + --hash=sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6 \ + --hash=sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5 \ + --hash=sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9 \ + --hash=sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072 \ + --hash=sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5 \ + --hash=sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277 \ + --hash=sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a \ + --hash=sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6 \ + --hash=sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae \ + --hash=sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26 \ + --hash=sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2 \ + --hash=sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4 \ + --hash=sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70 \ + --hash=sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723 \ + --hash=sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c \ + --hash=sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9 \ + --hash=sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5 \ + --hash=sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e \ + --hash=sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c \ + --hash=sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4 \ + --hash=sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0 \ + --hash=sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2 \ + --hash=sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b \ + --hash=sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7 \ + --hash=sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750 \ + --hash=sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2 \ + --hash=sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474 \ + --hash=sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716 \ + --hash=sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7 \ + --hash=sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123 \ + --hash=sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007 \ + --hash=sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595 \ + --hash=sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe \ + --hash=sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea \ + --hash=sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598 \ + --hash=sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679 \ + --hash=sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8 \ + --hash=sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83 \ + --hash=sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6 \ + --hash=sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f \ + --hash=sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94 \ + --hash=sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51 \ + --hash=sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120 \ + --hash=sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039 \ + --hash=sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1 \ + --hash=sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05 \ + --hash=sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb \ + --hash=sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144 \ + --hash=sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa \ + --hash=sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a \ + --hash=sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99 \ + --hash=sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928 \ + --hash=sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d \ + --hash=sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3 \ + --hash=sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434 \ + --hash=sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86 \ + --hash=sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46 \ + --hash=sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319 \ + --hash=sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67 \ + --hash=sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c \ + --hash=sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169 \ + --hash=sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c \ + --hash=sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59 \ + --hash=sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107 \ + --hash=sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4 \ + --hash=sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a \ + --hash=sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb \ + --hash=sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f \ + --hash=sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769 \ + --hash=sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432 \ + --hash=sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090 \ + --hash=sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764 \ + --hash=sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d \ + --hash=sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4 \ + --hash=sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b \ + --hash=sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d \ + --hash=sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543 \ + --hash=sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24 \ + --hash=sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5 \ + --hash=sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b \ + --hash=sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d \ + --hash=sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b \ + --hash=sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6 \ + --hash=sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735 \ + --hash=sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e \ + --hash=sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28 \ + --hash=sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3 \ + --hash=sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401 \ + --hash=sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6 \ + --hash=sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d # via # aiohttp # ocotilloapi diff --git a/schedule b/schedule index cadb867e..4b43cb08 100644 --- a/schedule +++ b/schedule @@ -1,9 +1,19 @@ +Use Cloud Scheduler to keep the primary API warm during business hours without +paying for a dedicated instance overnight. -this is used to add a schedule. -This schedule is used to keeping the api runingl - -gcloud scheduler jobs create http keep-alive-job \ - --schedule="*/10 8-18 * * 1-5" \ +Production: +gcloud scheduler jobs create http ocotillo-api-business-hours-warmup \ + --location=us-west4 \ + --schedule="*/5 8-18 * * 1-5" \ + --time-zone="America/Denver" \ --uri="https://ocotillo-api-dot-waterdatainitiative-271000.appspot.com/_ah/warmup" \ - --http-method=GET \ No newline at end of file + --http-method=GET + +Staging: +gcloud scheduler jobs create http ocotillo-api-staging-business-hours-warmup \ + --location=us-west4 \ + --schedule="*/5 8-18 * * 1-5" \ + --time-zone="America/Denver" \ + --uri="https://ocotillo-api-staging-dot-waterdatainitiative-271000.appspot.com/_ah/warmup" \ + --http-method=GET diff --git a/schemas/contact.py b/schemas/contact.py index 590d6db8..d6fe28a0 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -151,7 +151,7 @@ class CreateContact(BaseCreateModel, ValidateContact): name: str | None = None organization: str | None = None role: Role - contact_type: ContactType = "Primary" + contact_type: ContactType nma_pk_owners: str | None = None # description: str | None = None # email: str | None = None diff --git a/schemas/location.py b/schemas/location.py index 59654528..50fe28dd 100644 --- a/schemas/location.py +++ b/schemas/location.py @@ -101,6 +101,9 @@ class GeoJSONProperties(BaseModel): elevation_unit: str = "ft" vertical_datum: str = "NAVD88" elevation_method: ElevationMethod | None + county: str | None = None + state: str | None = None + quad_name: str | None = None utm_coordinates: GeoJSONUTMCoordinates = Field( default_factory=GeoJSONUTMCoordinates ) @@ -154,6 +157,9 @@ def populate_fields(cls, data: Any) -> Any: data_dict["properties"]["notes"] = data_dict.get("notes") data_dict["properties"]["elevation"] = convert_m_to_ft(elevation_m) data_dict["properties"]["elevation_method"] = data_dict.get("elevation_method") + data_dict["properties"]["county"] = data_dict.get("county") + data_dict["properties"]["state"] = data_dict.get("state") + data_dict["properties"]["quad_name"] = data_dict.get("quad_name") data_dict["properties"]["nma_location_notes"] = data_dict.get( "nma_location_notes" ) diff --git a/schemas/thing.py b/schemas/thing.py index ad109bf0..baa4ec6c 100644 --- a/schemas/thing.py +++ b/schemas/thing.py @@ -28,6 +28,8 @@ WellPumpType, FormationCode, OriginType, + Role, + ContactType, ) from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel, PastOrTodayDate from schemas.group import GroupResponse @@ -143,6 +145,7 @@ class CreateWell(CreateBaseThing, ValidateWell): is_suitable_for_datalogger: bool | None = None is_open: bool | None = None well_status: str | None = None + monitoring_status: str | None = None formation_completion_code: FormationCode | None = None nma_formation_zone: str | None = None @@ -228,6 +231,13 @@ def remove_records_with_end_date(cls, monitoring_frequencies): return active_frequencies +class WellContactSummaryResponse(BaseResponseModel): + name: str | None = None + organization: str | None = None + role: Role + contact_type: ContactType + + class WellResponse(BaseThingResponse): """ Response schema for well details. @@ -261,6 +271,7 @@ class WellResponse(BaseThingResponse): aquifers: list[dict] = [] water_notes: list[NoteResponse] = [] construction_notes: list[NoteResponse] = [] + contacts: list[WellContactSummaryResponse] = [] permissions: list[PermissionHistoryResponse] formation_completion_code: FormationCode | None nma_formation_zone: str | None diff --git a/schemas/water_level_csv.py b/schemas/water_level_csv.py index 00d71eaf..32f33333 100644 --- a/schemas/water_level_csv.py +++ b/schemas/water_level_csv.py @@ -13,7 +13,219 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from pydantic import BaseModel +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Annotated + +from core.enums import DataQuality, GroundwaterLevelReason, SampleMethod +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) +from pydantic.functional_validators import BeforeValidator + +from services.util import convert_dt_tz_naive_to_tz_aware + +WATER_LEVEL_REQUIRED_FIELDS = [ + "well_name_point_id", + "field_event_date_time", + "field_staff", + "water_level_date_time", + "measuring_person", + "sample_method", +] + +WATER_LEVEL_HEADER_ALIASES = { + "measurement_date_time": "water_level_date_time", + "sampler": "measuring_person", + "mp_height_ft": "mp_height", +} + +WATER_LEVEL_IGNORED_FIELDS = { + "hold(not saved)", + "cut(not saved)", +} + +SAMPLE_METHOD_ALIASES = { + "electric tape": "Electric tape measurement (E-probe)", + "steel tape": "Steel-tape measurement", +} +SAMPLE_METHOD_CANONICAL = { + value.lower(): value for value in SAMPLE_METHOD_ALIASES.values() +} +GROUNDWATER_LEVEL_REASON_ALIASES = { + "dry": "Site was dry", + "obstructed": ("Obstruction was encountered in the well (no level recorded)"), + "obstruction": ("Obstruction was encountered in the well (no level recorded)"), + "flowing": ( + "Site was flowing. Water level or head couldn't be measured " + "w/out additional equipment." + ), + "flowing recently": "Site was flowing recently.", + "pumped": "Site was being pumped", + "pumped recently": "Site was pumped recently", + "not affected": "Water level not affected", + "other": "Other conditions exist that would affect the level (remarks)", +} + + +def empty_str_to_none(value): + if isinstance(value, str) and value.strip() == "": + return None + return value + + +OptionalText = Annotated[str | None, BeforeValidator(empty_str_to_none)] +OptionalFloat = Annotated[float | None, BeforeValidator(empty_str_to_none)] + + +def _normalize_datetime_to_utc(value: datetime | str) -> datetime: + if isinstance(value, str): + value = datetime.fromisoformat(value) + elif not isinstance(value, datetime): + raise ValueError("value must be a datetime or ISO format string") + + if value.tzinfo is None: + value = convert_dt_tz_naive_to_tz_aware(value, "America/Denver") + + return value.astimezone(timezone.utc) + + +def _canonicalize_enum_value( + value: str | None, enum_cls, field_name: str +) -> str | None: + if value is None: + return None + + normalized = value.strip().lower() + for item in enum_cls: + if item.value.lower() == normalized: + return item.value + + raise ValueError(f"Unknown {field_name}: {value}") + + +class WaterLevelCsvRow(BaseModel): + model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) + + well_name_point_id: str + field_event_date_time: datetime + field_staff: str + field_staff_2: OptionalText = None + field_staff_3: OptionalText = None + water_level_date_time: datetime = Field( + validation_alias=AliasChoices( + "water_level_date_time", + "measurement_date_time", + ) + ) + measuring_person: str = Field( + validation_alias=AliasChoices("measuring_person", "sampler") + ) + sample_method: str + mp_height: OptionalFloat = Field( + default=None, + validation_alias=AliasChoices("mp_height", "mp_height_ft"), + ) + level_status: OptionalText = None + depth_to_water_ft: OptionalFloat = None + data_quality: OptionalText = None + water_level_notes: OptionalText = None + + @property + def measurement_date_time(self) -> datetime: + return self.water_level_date_time + + @property + def sampler(self) -> str: + return self.measuring_person + + @classmethod + def required_fields(cls) -> list[str]: + return list(WATER_LEVEL_REQUIRED_FIELDS) + + @classmethod + def header_aliases(cls) -> dict[str, str]: + return dict(WATER_LEVEL_HEADER_ALIASES) + + @classmethod + def ignored_fields(cls) -> set[str]: + return set(WATER_LEVEL_IGNORED_FIELDS) + + @staticmethod + def canonicalize_sample_method(value: str) -> str: + normalized = value.strip().lower() + if normalized in SAMPLE_METHOD_ALIASES: + return SAMPLE_METHOD_ALIASES[normalized] + if normalized in SAMPLE_METHOD_CANONICAL: + return SAMPLE_METHOD_CANONICAL[normalized] + return value.strip() + + @field_validator("sample_method") + @classmethod + def normalize_sample_method(cls, value: str) -> str: + return _canonicalize_enum_value( + cls.canonicalize_sample_method(value), + SampleMethod, + "sample_method", + ) + + @field_validator( + "field_event_date_time", + "water_level_date_time", + mode="before", + ) + @classmethod + def normalize_datetime_field(cls, value: datetime | str) -> datetime: + return _normalize_datetime_to_utc(value) + + @field_validator("depth_to_water_ft") + @classmethod + def validate_non_negative_depth_to_water(cls, value: float | None) -> float | None: + if value is not None and value < 0: + raise ValueError("depth_to_water_ft must be greater than or equal to 0") + return value + + @field_validator("level_status") + @classmethod + def normalize_level_status(cls, value: str | None) -> str | None: + if value is not None: + value = GROUNDWATER_LEVEL_REASON_ALIASES.get(value.strip().lower(), value) + return _canonicalize_enum_value(value, GroundwaterLevelReason, "level_status") + + @field_validator("data_quality") + @classmethod + def normalize_data_quality(cls, value: str | None) -> str | None: + return _canonicalize_enum_value(value, DataQuality, "data_quality") + + @model_validator(mode="after") + def validate_row_constraints(self) -> WaterLevelCsvRow: + field_staff = [ + staff + for staff in (self.field_staff, self.field_staff_2, self.field_staff_3) + if staff + ] + if self.measuring_person not in field_staff: + raise ValueError( + "measuring_person must match one of field_staff, " + "field_staff_2, or field_staff_3" + ) + + if self.water_level_date_time < self.field_event_date_time: + raise ValueError( + "water_level_date_time must be greater than or equal to " + "field_event_date_time" + ) + + if self.depth_to_water_ft is None and self.level_status is None: + raise ValueError("level_status is required when depth_to_water_ft is blank") + + return self class WaterLevelBulkUploadSummary(BaseModel): @@ -29,8 +241,8 @@ class WaterLevelBulkUploadRow(BaseModel): sample_id: int observation_id: int measurement_date_time: str - level_status: str - data_quality: str + level_status: str | None + data_quality: str | None class WaterLevelBulkUploadResponse(BaseModel): diff --git a/schemas/well_details.py b/schemas/well_details.py new file mode 100644 index 00000000..fa94f154 --- /dev/null +++ b/schemas/well_details.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, ConfigDict, Field + +from schemas.contact import ContactResponse +from schemas.deployment import DeploymentResponse +from schemas.observation import GroundwaterLevelObservationResponse +from schemas.sample import SampleResponse +from schemas.sensor import SensorResponse +from schemas.thing import WellResponse, WellScreenResponse + + +class WellDetailsResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + well: WellResponse + contacts: list[ContactResponse] = Field(default_factory=list) + sensors: list[SensorResponse] = Field(default_factory=list) + deployments: list[DeploymentResponse] = Field(default_factory=list) + well_screens: list[WellScreenResponse] = Field(default_factory=list) + recent_groundwater_level_observations: list[GroundwaterLevelObservationResponse] = ( + Field(default_factory=list) + ) + latest_field_event_sample: SampleResponse | None = None diff --git a/schemas/well_export.py b/schemas/well_export.py new file mode 100644 index 00000000..9259e3de --- /dev/null +++ b/schemas/well_export.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, ConfigDict, Field + +from schemas.contact import ContactResponse +from schemas.deployment import DeploymentResponse +from schemas.sensor import SensorResponse +from schemas.thing import WellResponse + + +class WellExportResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + well: WellResponse + contacts: list[ContactResponse] = Field(default_factory=list) + sensors: list[SensorResponse] = Field(default_factory=list) + deployments: list[DeploymentResponse] = Field(default_factory=list) diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index dd547725..56eb93eb 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -29,6 +29,12 @@ AddressType, WellPurpose as WellPurposeEnum, MonitoringFrequency, + OriginType, + WellPumpType, + MonitoringStatus, + SampleMethod, + DataQuality, + GroundwaterLevelReason, ) from phonenumbers import NumberParseException from pydantic import ( @@ -38,6 +44,8 @@ validate_email, AfterValidator, field_validator, + Field, + AliasChoices, ) from schemas import past_or_today_validator, PastOrTodayDatetime from services.util import convert_dt_tz_naive_to_tz_aware @@ -60,13 +68,6 @@ def owner_default(v): return v -def primary_default(v): - v = blank_to_none(v) - if v is None: - return "Primary" - return v - - US_POSTAL_REGEX = re.compile(r"^\d{5}(-\d{4})?$") @@ -122,28 +123,75 @@ def email_validator_function(email_str): raise ValueError(f"Invalid email format. {email_str}") from e +def flexible_lexicon_validator(enum_cls): + def validator(v): + if v is None: + return None + if isinstance(v, str) and v.strip() == "": + return None + if isinstance(v, enum_cls): + return v + + v_str = str(v).strip().lower() + for item in enum_cls: + if item.value.lower() == v_str: + return item + return v + + return validator + + # Reusable type PhoneTypeField: TypeAlias = Annotated[ - Optional[PhoneType], BeforeValidator(blank_to_none) + Optional[PhoneType], BeforeValidator(flexible_lexicon_validator(PhoneType)) ] ContactTypeField: TypeAlias = Annotated[ - Optional[ContactType], BeforeValidator(blank_to_none) + Optional[ContactType], + BeforeValidator(flexible_lexicon_validator(ContactType)), ] EmailTypeField: TypeAlias = Annotated[ - Optional[EmailType], BeforeValidator(blank_to_none) + Optional[EmailType], BeforeValidator(flexible_lexicon_validator(EmailType)) ] AddressTypeField: TypeAlias = Annotated[ - Optional[AddressType], BeforeValidator(blank_to_none) + Optional[AddressType], BeforeValidator(flexible_lexicon_validator(AddressType)) +] +ContactRoleField: TypeAlias = Annotated[ + Optional[Role], BeforeValidator(flexible_lexicon_validator(Role)) ] -ContactRoleField: TypeAlias = Annotated[Optional[Role], BeforeValidator(blank_to_none)] OptionalFloat: TypeAlias = Annotated[ Optional[float], BeforeValidator(empty_str_to_none) ] MonitoringFrequencyField: TypeAlias = Annotated[ - Optional[MonitoringFrequency], BeforeValidator(blank_to_none) + Optional[MonitoringFrequency], + BeforeValidator(flexible_lexicon_validator(MonitoringFrequency)), ] WellPurposeField: TypeAlias = Annotated[ - Optional[WellPurposeEnum], BeforeValidator(blank_to_none) + Optional[WellPurposeEnum], + BeforeValidator(flexible_lexicon_validator(WellPurposeEnum)), +] +OriginTypeField: TypeAlias = Annotated[ + Optional[OriginType], BeforeValidator(flexible_lexicon_validator(OriginType)) +] +WellPumpTypeField: TypeAlias = Annotated[ + Optional[WellPumpType], BeforeValidator(flexible_lexicon_validator(WellPumpType)) +] +MonitoringStatusField: TypeAlias = Annotated[ + Optional[MonitoringStatus], + BeforeValidator(flexible_lexicon_validator(MonitoringStatus)), +] +WellStatusField: TypeAlias = Annotated[ + Optional[MonitoringStatus], + BeforeValidator(flexible_lexicon_validator(MonitoringStatus)), +] +SampleMethodField: TypeAlias = Annotated[ + Optional[SampleMethod], BeforeValidator(flexible_lexicon_validator(SampleMethod)) +] +DataQualityField: TypeAlias = Annotated[ + Optional[DataQuality], BeforeValidator(flexible_lexicon_validator(DataQuality)) +] +GroundwaterLevelReasonField: TypeAlias = Annotated[ + Optional[GroundwaterLevelReason], + BeforeValidator(flexible_lexicon_validator(GroundwaterLevelReason)), ] PostalCodeField: TypeAlias = Annotated[ Optional[str], BeforeValidator(postal_code_or_none) @@ -153,6 +201,7 @@ def email_validator_function(email_str): EmailField: TypeAlias = Annotated[ Optional[str], BeforeValidator(email_validator_function) ] +OptionalText: TypeAlias = Annotated[Optional[str], BeforeValidator(empty_str_to_none)] OptionalBool: TypeAlias = Annotated[Optional[bool], BeforeValidator(empty_str_to_none)] OptionalPastOrTodayDateTime: TypeAlias = Annotated[ @@ -170,23 +219,26 @@ def email_validator_function(email_str): class WellInventoryRow(BaseModel): # Required fields project: str - well_name_point_id: str - site_name: str + well_name_point_id: Optional[str] = None date_time: PastOrTodayDatetime field_staff: str utm_easting: float utm_northing: float utm_zone: str - elevation_ft: float - elevation_method: ElevationMethod - measuring_point_height_ft: float # Optional fields - field_staff_2: Optional[str] = None - field_staff_3: Optional[str] = None - - contact_1_name: Optional[str] = None - contact_1_organization: Optional[str] = None + site_name: OptionalText = None + elevation_ft: OptionalFloat = None + elevation_method: Annotated[ + Optional[ElevationMethod], + BeforeValidator(flexible_lexicon_validator(ElevationMethod)), + ] = None + measuring_point_height_ft: OptionalFloat = None + field_staff_2: OptionalText = None + field_staff_3: OptionalText = None + + contact_1_name: OptionalText = None + contact_1_organization: OptionalText = None contact_1_role: ContactRoleField = None contact_1_type: ContactTypeField = None contact_1_phone_1: PhoneField = None @@ -210,8 +262,8 @@ class WellInventoryRow(BaseModel): contact_1_address_2_city: Optional[str] = None contact_1_address_2_postal_code: PostalCodeField = None - contact_2_name: Optional[str] = None - contact_2_organization: Optional[str] = None + contact_2_name: OptionalText = None + contact_2_organization: OptionalText = None contact_2_role: ContactRoleField = None contact_2_type: ContactTypeField = None contact_2_phone_1: PhoneField = None @@ -240,15 +292,15 @@ class WellInventoryRow(BaseModel): repeat_measurement_permission: OptionalBool = None sampling_permission: OptionalBool = None datalogger_installation_permission: OptionalBool = None - public_availability_acknowledgement: OptionalBool = None # TODO: needs a home + public_availability_acknowledgement: OptionalBool = None special_requests: Optional[str] = None ose_well_record_id: Optional[str] = None date_drilled: OptionalPastOrTodayDate = None - completion_source: Optional[str] = None + completion_source: OriginTypeField = None total_well_depth_ft: OptionalFloat = None historic_depth_to_water_ft: OptionalFloat = None - depth_source: Optional[str] = None - well_pump_type: Optional[str] = None + depth_source: OriginTypeField = None + well_pump_type: WellPumpTypeField = None well_pump_depth_ft: OptionalFloat = None is_open: OptionalBool = None datalogger_possible: OptionalBool = None @@ -256,24 +308,58 @@ class WellInventoryRow(BaseModel): measuring_point_description: Optional[str] = None well_purpose: WellPurposeField = None well_purpose_2: WellPurposeField = None - well_status: Optional[str] = None + well_status: WellStatusField = Field( + default=None, + validation_alias=AliasChoices("well_status", "well_hole_status"), + ) monitoring_frequency: MonitoringFrequencyField = None + monitoring_status: MonitoringStatusField = None result_communication_preference: Optional[str] = None contact_special_requests_notes: Optional[str] = None sampling_scenario_notes: Optional[str] = None + well_notes: Optional[str] = None + water_notes: Optional[str] = None well_measuring_notes: Optional[str] = None - sample_possible: OptionalBool = None # TODO: needs a home + sample_possible: OptionalBool = None # water levels - sampler: Optional[str] = None - sample_method: Optional[str] = None - measurement_date_time: OptionalPastOrTodayDateTime = None - mp_height: Optional[float] = None - level_status: Optional[str] = None - depth_to_water_ft: Optional[float] = None - data_quality: Optional[str] = None - water_level_notes: Optional[str] = None # TODO: needs a home + sampler: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("sampler", "measuring_person"), + ) + sample_method: SampleMethodField = None + measurement_date_time: OptionalPastOrTodayDateTime = Field( + default=None, + validation_alias=AliasChoices("measurement_date_time", "water_level_date_time"), + ) + mp_height: OptionalFloat = Field( + default=None, + validation_alias=AliasChoices("mp_height", "mp_height_ft"), + ) + level_status: GroundwaterLevelReasonField = None + depth_to_water_ft: OptionalFloat = None + data_quality: DataQualityField = None + water_level_notes: Optional[str] = None + + @model_validator(mode="before") + @classmethod + def normalize_complete_monitoring_frequency(cls, data): + """Normalize `Complete` monitoring_frequency by clearing monitoring_frequency and setting monitoring_status to `Not currently monitored`.""" + if not isinstance(data, dict): + return data + + monitoring_frequency = data.get("monitoring_frequency") + if ( + isinstance(monitoring_frequency, str) + and monitoring_frequency.strip().lower() == "complete" + ): + normalized = dict(data) + normalized["monitoring_frequency"] = None + normalized["monitoring_status"] = "Not currently monitored" + return normalized + + return data @field_validator("date_time", mode="before") def make_date_time_tz_aware(cls, v): @@ -292,23 +378,6 @@ def make_date_time_tz_aware(cls, v): @model_validator(mode="after") def validate_model(self): - - optional_wl = ( - "sampler", - "sample_method", - "measurement_date_time", - "mp_height", - "level_status", - "depth_to_water_ft", - "data_quality", - "water_level_notes", - ) - - wl_fields = [getattr(self, a) for a in optional_wl] - if any(wl_fields): - if not all(wl_fields): - raise ValueError("All water level fields must be provided") - # verify utm in NM utm_zone_value = (self.utm_zone or "").upper() if utm_zone_value not in ("12N", "13N"): @@ -325,38 +394,51 @@ def validate_model(self): f" Zone={self.utm_zone}" ) + if self.depth_to_water_ft is not None: + if self.measurement_date_time is None: + raise ValueError( + "water_level_date_time is required when depth_to_water_ft is provided" + ) + required_attrs = ("line_1", "type", "state", "city", "postal_code") all_attrs = ("line_1", "line_2", "type", "state", "city", "postal_code") for jdx in (1, 2): key = f"contact_{jdx}" - # Check if any contact data is provided name = getattr(self, f"{key}_name") organization = getattr(self, f"{key}_organization") + role = getattr(self, f"{key}_role") + contact_type = getattr(self, f"{key}_type") + + # Treat name or organization as contact data too, so bare contacts + # still go through the same cross-field rules as fully populated ones. has_contact_data = any( [ name, organization, getattr(self, f"{key}_role"), getattr(self, f"{key}_type"), - *[getattr(self, f"{key}_email_{i}", None) for i in (1, 2)], - *[getattr(self, f"{key}_phone_{i}", None) for i in (1, 2)], + *[getattr(self, f"{key}_email_{i}") for i in (1, 2)], + *[getattr(self, f"{key}_phone_{i}") for i in (1, 2)], *[ - getattr(self, f"{key}_address_{i}_{a}", None) + getattr(self, f"{key}_address_{i}_{a}") for i in (1, 2) for a in all_attrs ], ] ) - # If any contact data is provided, both name and organization are required if has_contact_data: - if not name: + if not name and not organization: raise ValueError( - f"{key}_name is required when other contact fields are provided" + f"At least one of {key}_name or {key}_organization must be provided" ) - if not organization: + if not role: raise ValueError( - f"{key}_organization is required when other contact fields are provided" + f"{key}_role is required when contact data is provided" + ) + if not contact_type: + raise ValueError( + f"{key}_type is required when contact data is provided" ) for idx in (1, 2): if any(getattr(self, f"{key}_address_{idx}_{a}") for a in all_attrs): @@ -366,17 +448,6 @@ def validate_model(self): ): raise ValueError("All contact address fields must be provided") - name = getattr(self, f"{key}_name") - if name: - if not getattr(self, f"{key}_role"): - raise ValueError( - f"{key}_role must be provided if name is provided" - ) - if not getattr(self, f"{key}_type"): - raise ValueError( - f"{key}_type must be provided if name is provided" - ) - phone = getattr(self, f"{key}_phone_{idx}") tag = f"{key}_phone_{idx}_type" phone_type = getattr(self, f"{key}_phone_{idx}_type") diff --git a/services/asset_helper.py b/services/asset_helper.py index 83c48509..51a4654f 100644 --- a/services/asset_helper.py +++ b/services/asset_helper.py @@ -5,19 +5,21 @@ # 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 # =============================================================================== -from typing import BinaryIO +from typing import TYPE_CHECKING, BinaryIO -from google.cloud.storage import Bucket from sqlalchemy.orm import Session from db import AssetThingAssociation, Thing, Asset from services.gcs_helper import gcs_upload +if TYPE_CHECKING: + from google.cloud.storage import Bucket + def upload_and_associate( session: Session, ff: BinaryIO, - bucket: Bucket, + bucket: "Bucket", thing: Thing, name: str, **asset_args, diff --git a/services/contact_helper.py b/services/contact_helper.py index 2aed7458..05b66200 100644 --- a/services/contact_helper.py +++ b/services/contact_helper.py @@ -114,16 +114,16 @@ def add_contact( if commit: session.commit() + session.refresh(contact) + + for note in contact.notes: + session.refresh(note) else: session.flush() - session.refresh(contact) - - for note in contact.notes: - session.refresh(note) - except Exception as e: - session.rollback() + if commit: + session.rollback() raise e return contact diff --git a/services/env.py b/services/env.py new file mode 100644 index 00000000..7bc06caa --- /dev/null +++ b/services/env.py @@ -0,0 +1,16 @@ +import os + + +def to_bool(value: str) -> bool | str: + """Convert common string environment values to booleans.""" + if isinstance(value, bool): + return value + if value.lower() in ("true", "1", "yes"): + return True + if value.lower() in ("false", "0", "no"): + return False + return value + + +def get_bool_env(key, default=False): + return to_bool(os.getenv(key, default)) diff --git a/services/gcs_helper.py b/services/gcs_helper.py index 4a45fa50..da9ce606 100644 --- a/services/gcs_helper.py +++ b/services/gcs_helper.py @@ -16,23 +16,36 @@ import base64 import datetime import json +import logging import os +import time +from functools import lru_cache from hashlib import md5 from fastapi import UploadFile -from google.oauth2 import service_account from sqlalchemy import select from core.settings import settings from db import Asset, AssetThingAssociation +from services.env import get_bool_env GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME") GCS_BUCKET_BASE_URL = f"https://storage.cloud.google.com/{GCS_BUCKET_NAME}/uploads" +GCS_LOOKUP_TIMEOUT_SECS = float(os.environ.get("GCS_LOOKUP_TIMEOUT_SECS", "15")) +GCS_UPLOAD_TIMEOUT_SECS = float(os.environ.get("GCS_UPLOAD_TIMEOUT_SECS", "120")) +logger = logging.getLogger(__name__) +HASH_CHUNK_SIZE = 1024 * 1024 -from google.cloud import storage +def is_debug_timing_enabled() -> bool: + return bool(get_bool_env("API_DEBUG_TIMING", False)) + + +@lru_cache(maxsize=1) +def get_storage_client(): + from google.cloud import storage + from google.oauth2 import service_account -def get_storage_client() -> storage.Client: if settings.mode == "production": key_base64 = os.environ.get("GCS_SERVICE_ACCOUNT_KEY") decoded = base64.b64decode(key_base64).decode("utf-8") @@ -45,59 +58,140 @@ def get_storage_client() -> storage.Client: # Create storage client client = storage.Client(credentials=creds) else: - # Use application default credentials (from ~/.config/gcloud/application_default_credentials.json) - # This will automatically use GOOGLE_APPLICATION_CREDENTIALS if set, or the default location + # Use application default credentials from gcloud or + # GOOGLE_APPLICATION_CREDENTIALS when present. client = storage.Client() return client -def get_storage_bucket(client=None, bucket: str = None) -> storage.Bucket: - if client is None: - client = get_storage_client() +@lru_cache(maxsize=8) +def _get_cached_bucket(bucket_name: str): + return get_storage_client().bucket(bucket_name) + + +def get_storage_bucket(client=None, bucket: str = None): + bucket_name = bucket or GCS_BUCKET_NAME + if client is not None: + return client.bucket(bucket_name) + return _get_cached_bucket(bucket_name) - if bucket is None: - bucket = GCS_BUCKET_NAME - return client.bucket(bucket) +def _log_stage(stage: str, started_at: float, **extra): + if not is_debug_timing_enabled(): + return + record_extra = { + "event": "gcs_stage_timing", + "stage": stage, + "duration_ms": round((time.perf_counter() - started_at) * 1000, 2), + } + if "filename" in extra: + record_extra["upload_filename"] = extra.pop("filename") + logger.info( + "gcs stage timing", + extra={**record_extra, **extra}, + ) + + +def _hash_file(file_obj) -> str: + hasher = md5() + while True: + chunk = file_obj.read(HASH_CHUNK_SIZE) + if not chunk: + break + hasher.update(chunk) + return hasher.hexdigest() def make_blob_name_and_uri(file): + started_at = time.perf_counter() head, extension = os.path.splitext(file.filename) - file_id = md5(file.file.read()).hexdigest() + file.file.seek(0) + file_id = _hash_file(file.file) + file.file.seek(0) blob_name = f"{head}_{file_id}{extension}" uri = f"{GCS_BUCKET_BASE_URL}/{blob_name}" + _log_stage( + "hash_file", + started_at, + filename=file.filename, + blob_name=blob_name, + ) return blob_name, uri -def gcs_upload(file: UploadFile, bucket: storage.Bucket = None): +def gcs_upload(file: UploadFile, bucket=None): + upload_started_at = time.perf_counter() if bucket is None: + bucket_started_at = time.perf_counter() bucket = get_storage_bucket() + _log_stage("resolve_bucket", bucket_started_at, filename=file.filename) # make file id from hash of file contents file.file.seek(0) blob_name, uri = make_blob_name_and_uri(file) - eblob = bucket.get_blob(blob_name) + lookup_started_at = time.perf_counter() + eblob = bucket.get_blob(blob_name, timeout=GCS_LOOKUP_TIMEOUT_SECS) + _log_stage( + "lookup_blob", + lookup_started_at, + filename=file.filename, + blob_name=blob_name, + blob_exists=eblob is not None, + ) if not eblob: blob = bucket.blob(blob_name) file.file.seek(0) - blob.upload_from_file(file.file, content_type=file.content_type) + upload_blob_started_at = time.perf_counter() + blob.upload_from_file( + file.file, + content_type=file.content_type, + timeout=GCS_UPLOAD_TIMEOUT_SECS, + ) + _log_stage( + "upload_blob", + upload_blob_started_at, + filename=file.filename, + blob_name=blob_name, + ) + _log_stage( + "upload_request_total", + upload_started_at, + filename=file.filename, + blob_name=blob_name, + ) return uri, blob_name -def gcs_remove(uri: str, bucket: storage.Bucket): +def gcs_remove(uri: str, bucket): blob = bucket.blob(uri) blob.delete() -def add_signed_url(asset: Asset, bucket: storage.Bucket): - asset.signed_url = bucket.blob(asset.storage_path).generate_signed_url( - version="v4", - expiration=datetime.timedelta(minutes=15), - method="GET", - ) +def add_signed_url(asset: Asset, bucket): + started_at = time.perf_counter() + try: + asset.signed_url = bucket.blob(asset.storage_path).generate_signed_url( + version="v4", + expiration=datetime.timedelta(minutes=15), + method="GET", + ) + _log_stage( + "generate_signed_url", + started_at, + asset_id=getattr(asset, "id", None), + storage_path=getattr(asset, "storage_path", None), + ) + except Exception: + logger.warning( + "Failed to generate signed URL for asset_id=%s storage_path=%s", + getattr(asset, "id", None), + getattr(asset, "storage_path", None), + exc_info=True, + ) + asset.signed_url = None return asset diff --git a/services/ngwmn_helper.py b/services/ngwmn_helper.py index 84a8026d..73df1158 100644 --- a/services/ngwmn_helper.py +++ b/services/ngwmn_helper.py @@ -17,6 +17,11 @@ from sqlalchemy import text + +def _as_text(v): + return "" if v is None else str(v) + + # NSMAP = dict(xsi="http://www.w3.org/2001/XMLSchema-instance", xsd="http://www.w3.org/2001/XMLSchema") @@ -26,27 +31,36 @@ def make_xml_response(db, sql, point_id, func): rs = [] for si in sql: - records = db.execute(text(si), {"point_id": point_id}) rs.append(records.fetchall()) return func(*rs) def make_lithology_response(point_id, db): - sql = "select * from NMA_view_NGWMN_Lithology where PointID=:point_id" + sql = ( + 'select "PointID", "StratTop", "StratBottom", "TERM" ' + 'from "NMA_view_NGWMN_Lithology" where "PointID"=:point_id' + ) return make_xml_response(db, sql, point_id, lithology_xml) def make_well_construction_response(point_id, db): - sql = "select * from NMA_view_NGWMN_WellConstruction where PointID=:point_id" + sql = ( + 'select "PointID", "CasingTop", "CasingBottom", "CasingDepthUnits", ' + '"ScreenTop", "ScreenBottom", "ScreenBottomUnit", "ScreenDescription", "CasingDescription" ' + 'from "NMA_view_NGWMN_WellConstruction" where "PointID"=:point_id' + ) return make_xml_response(db, sql, point_id, well_construction_xml) def make_waterlevels_response(point_id, db): - sql = "select * from dbo.view_NGWMN_WaterLevels where PointID=:point_id order by DateMeasured" + sql = ( + 'select * from "NMA_view_NGWMN_WaterLevels" where "PointID"=:point_id ' + 'order by "DateMeasured"' + ) sql2 = ( - "select * from NMA_WaterLevelsContinuous_Pressure_Daily where PointID=:point_id and QCed=1 order by " - "DateMeasured" + 'select * from "NMA_WaterLevelsContinuous_Pressure_Daily" where "PointID"=:point_id and "QCed" is true ' + 'order by "DateMeasured"' ) return make_xml_response(db, (sql, sql2), point_id, water_levels_xml2) @@ -182,7 +196,7 @@ def make_continuous_water_level(root, r): ("WaterLevelAccuracy", "0.02 ft"), ): e = etree.SubElement(elem, attr) - e.text = str(val) + e.text = _as_text(val) def make_water_level(root, r): @@ -204,37 +218,37 @@ def make_water_level(root, r): ("WaterLevelAccuracy", r[5]), ): e = etree.SubElement(elem, attr) - e.text = str(val) + e.text = _as_text(val) def make_well_construction(root, r): """ - 0 1 2 3 4 5 6, 7, 8 - pointid, castop, casbottom, cadepthunits, screentop, screenbotom, units,screen description, casing description + 0 1 2 3 4 5 6 7 8 + pointid, castop, casbottom, cadepthunits, screentop, screenbottom, screenbottomunit, screen description, casing description :param root: :param r: :return: """ elem = etree.SubElement(root, "Casing") - make_point_id(elem, r) + make_point_id(elem, r, idx=0) e = etree.SubElement(elem, "CasingTop") - e.text = str(r[1]) + e.text = _as_text(r[1]) e = etree.SubElement(elem, "CasingBottom") - e.text = str(r[2]) + e.text = _as_text(r[2]) e = etree.SubElement(elem, "CasingDepthUnits") - e.text = str(r[3]) + e.text = _as_text(r[3]) e = etree.SubElement(elem, "ScreenTop") - e.text = str(r[4]) + e.text = _as_text(r[4]) e = etree.SubElement(elem, "ScreenBottom") - e.text = str(r[5]) + e.text = _as_text(r[5]) e = etree.SubElement(elem, "ScreenDescription") - e.text = str(r[7]) + e.text = _as_text(r[7]) e = etree.SubElement(elem, "ScreenMaterial") e.text = "steel" @@ -242,21 +256,22 @@ def make_well_construction(root, r): def make_lithology(root, r): elem = etree.SubElement(root, "Lithology") - make_point_id(elem, r) + make_point_id(elem, r, idx=0) e = etree.SubElement(elem, "TopDepth") - e.text = str(r[1]) + e.text = _as_text(r[1]) e = etree.SubElement(elem, "BottomDepth") - e.text = str(r[2]) + e.text = _as_text(r[2]) e = etree.SubElement(elem, "Units") e.text = "feet" e = etree.SubElement(elem, "Description") - e.text = str(r[3]) + e.text = _as_text(r[3]) def make_point_id(elem, r, idx=0): e = etree.SubElement(elem, "PointID") - e.text = r[idx] + v = r[idx] + e.text = _as_text(v) diff --git a/services/observation_helper.py b/services/observation_helper.py index af24af05..f99241db 100644 --- a/services/observation_helper.py +++ b/services/observation_helper.py @@ -1,4 +1,6 @@ from datetime import datetime +import logging +import time from typing import List from fastapi import Request, Query @@ -24,8 +26,15 @@ GroundwaterLevelObservationResponse, ) from services.exceptions_helper import PydanticStyleException +from services.env import get_bool_env from services.query_helper import simple_get_by_id, order_sort_filter +logger = logging.getLogger(__name__) + + +def is_debug_timing_enabled() -> bool: + return bool(get_bool_env("API_DEBUG_TIMING", False)) + def get_activity_type_from_request(request: Request) -> str: path = request.url.path @@ -52,34 +61,25 @@ def get_transducer_observations( order: str | None = None, filter_: str = Query(alias="filter", default=None), ): + deployment_rows: list[tuple[int, int]] = [] + deployment_to_thing: dict[int, int] = {} + if thing_id: item = session.get(Thing, thing_id) if item is None: empty_query = select(TransducerObservation).where(False) return paginate(query=empty_query, conn=session) - - # Subquery to get latest block for each observation - block_subq = ( - select(TransducerObservationBlock.id) - .where( - TransducerObservationBlock.parameter_id - == TransducerObservation.parameter_id, - TransducerObservationBlock.start_datetime - <= TransducerObservation.observation_datetime, - TransducerObservationBlock.end_datetime - >= TransducerObservation.observation_datetime, - ) - .order_by(desc(TransducerObservationBlock.start_datetime)) - .limit(1) - .correlate(TransducerObservation) - .scalar_subquery() - ) - - query = ( - select(TransducerObservation, TransducerObservationBlock) - .join(Deployment, TransducerObservation.deployment_id == Deployment.id) - .join(TransducerObservationBlock, TransducerObservationBlock.id == block_subq) - ) + deployment_rows = session.execute( + select(Deployment.id, Deployment.thing_id).where( + Deployment.thing_id == thing_id + ) + ).all() + deployment_to_thing = { + deployment_id: deployment_thing_id + for deployment_id, deployment_thing_id in deployment_rows + } + + query = select(TransducerObservation) if start_time: query = query.where(TransducerObservation.observation_datetime >= start_time) @@ -89,23 +89,104 @@ def get_transducer_observations( if parameter_id: query = query.where(TransducerObservation.parameter_id == parameter_id) if thing_id: - query = query.where(Deployment.thing_id == thing_id) + deployment_ids = list(deployment_to_thing) + if not deployment_ids: + empty_query = select(TransducerObservation).where(False) + return paginate(query=empty_query, conn=session) + query = query.where(TransducerObservation.deployment_id.in_(deployment_ids)) - def transformer(result): + def transformer(observations): from schemas.transducer import ( TransducerObservationWithBlockResponse, TransducerObservationResponse, TransducerObservationBlockResponse, ) - return [ - TransducerObservationWithBlockResponse( - observation=TransducerObservationResponse.model_validate(observation), - block=TransducerObservationBlockResponse.model_validate(block), - ).model_dump() - for observation, block in result + if not observations: + return [] + + deployment_ids = {observation.deployment_id for observation in observations} + if not deployment_to_thing or not deployment_ids.issubset(deployment_to_thing): + deployment_rows = session.execute( + select(Deployment.id, Deployment.thing_id).where( + Deployment.id.in_(deployment_ids) + ) + ).all() + deployment_to_thing.update( + { + deployment_id: deployment_thing_id + for deployment_id, deployment_thing_id in deployment_rows + } + ) + + thing_ids = { + deployment_to_thing[observation.deployment_id] + for observation in observations + if observation.deployment_id in deployment_to_thing + } + parameter_ids = {observation.parameter_id for observation in observations} + observation_datetimes = [ + observation.observation_datetime for observation in observations ] + block_rows = session.scalars( + select(TransducerObservationBlock) + .where( + TransducerObservationBlock.thing_id.in_(thing_ids), + TransducerObservationBlock.parameter_id.in_(parameter_ids), + TransducerObservationBlock.start_datetime <= max(observation_datetimes), + TransducerObservationBlock.end_datetime >= min(observation_datetimes), + ) + .order_by( + TransducerObservationBlock.thing_id, + TransducerObservationBlock.parameter_id, + desc(TransducerObservationBlock.start_datetime), + ) + ).all() + + block_map: dict[tuple[int, int], list[TransducerObservationBlock]] = {} + for block in block_rows: + key = (block.thing_id, block.parameter_id) + if key not in block_map: + block_map[key] = [] + block_map[key].append(block) + + response_items = [] + for observation in observations: + thing_id_for_observation = deployment_to_thing.get( + observation.deployment_id + ) + if thing_id_for_observation is None: + continue + + matching_block = next( + ( + block + for block in block_map.get( + (thing_id_for_observation, observation.parameter_id), [] + ) + if block.start_datetime + <= observation.observation_datetime + <= block.end_datetime + ), + None, + ) + if matching_block is None: + continue + + response_items.append( + TransducerObservationWithBlockResponse( + observation=TransducerObservationResponse.model_validate( + observation + ), + block=TransducerObservationBlockResponse.model_validate( + matching_block + ), + ).model_dump() + ) + + return response_items + query = query.order_by(TransducerObservation.observation_datetime.desc()) return paginate(query=query, conn=session, transformer=transformer) @@ -163,7 +244,27 @@ def get_observations( if not order: sql = sql.order_by(Observation.observation_datetime.desc()) - return paginate(query=sql, conn=session) + started_at = time.perf_counter() + page = paginate(query=sql, conn=session) + if is_debug_timing_enabled(): + duration_ms = round((time.perf_counter() - started_at) * 1000, 2) + logger.info( + "observation query completed path=%s thing_id=%s sensor_id=%s sample_id=%s duration_ms=%s", + request.url.path, + thing_id, + sensor_id, + sample_id, + duration_ms, + extra={ + "event": "observation_query_completed", + "path": request.url.path, + "thing_id": thing_id, + "sensor_id": sensor_id, + "sample_id": sample_id, + "duration_ms": duration_ms, + }, + ) + return page def verify_observed_property_corresponds_with_activity_type( diff --git a/services/query_helper.py b/services/query_helper.py index 74835a33..379e2791 100644 --- a/services/query_helper.py +++ b/services/query_helper.py @@ -24,8 +24,8 @@ from starlette.status import HTTP_404_NOT_FOUND from db import search as search_func +from services.env import to_bool from services.regex import QUERY_REGEX -from services.util import to_bool def make_where(col: Column, op: str, v: str) -> OperatorExpression: diff --git a/services/sample_helper.py b/services/sample_helper.py index 20423de8..0f25dd9a 100644 --- a/services/sample_helper.py +++ b/services/sample_helper.py @@ -1,9 +1,85 @@ -from sqlalchemy.orm import Session, joinedload +from fastapi import HTTPException from fastapi_pagination.ext.sqlalchemy import paginate +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload, selectinload +from starlette.status import HTTP_404_NOT_FOUND -from db import FieldEvent, FieldActivity, FieldEventParticipant, Sample +from db import ( + Contact, + FieldActivity, + FieldEvent, + FieldEventParticipant, + GroupThingAssociation, + LocationThingAssociation, + Sample, + Thing, + ThingAquiferAssociation, + ThingContactAssociation, +) from services.query_helper import order_sort_filter +THING_RESPONSE_BASE = ( + joinedload(Sample.field_activity) + .joinedload(FieldActivity.field_event) + .joinedload(FieldEvent.thing) +) + + +THING_RESPONSE_LOADER_OPTIONS = ( + THING_RESPONSE_BASE.selectinload(Thing.location_associations).selectinload( + LocationThingAssociation.location + ), + THING_RESPONSE_BASE.selectinload(Thing.well_purposes), + THING_RESPONSE_BASE.selectinload(Thing.well_casing_materials), + THING_RESPONSE_BASE.selectinload(Thing.links), + THING_RESPONSE_BASE.selectinload(Thing.measuring_points), + THING_RESPONSE_BASE.selectinload(Thing.monitoring_frequencies), + THING_RESPONSE_BASE.selectinload(Thing.aquifer_associations).selectinload( + ThingAquiferAssociation.aquifer_system + ), + THING_RESPONSE_BASE.selectinload(Thing.group_associations).selectinload( + GroupThingAssociation.group + ), + THING_RESPONSE_BASE.selectinload(Thing.notes), + THING_RESPONSE_BASE.selectinload(Thing.permission_history), + THING_RESPONSE_BASE.selectinload(Thing.data_provenance), + THING_RESPONSE_BASE.selectinload(Thing.status_history), +) + + +CONTACT_RESPONSE_BASE = selectinload(Sample.field_event_participant) +CONTACT_RESPONSE_PARTICIPANT = CONTACT_RESPONSE_BASE.selectinload( + FieldEventParticipant.participant +) +CONTACT_RESPONSE_THING_ASSOCIATIONS = CONTACT_RESPONSE_PARTICIPANT.selectinload( + Contact.thing_associations +) +CONTACT_RESPONSE_THING = CONTACT_RESPONSE_THING_ASSOCIATIONS.selectinload( + ThingContactAssociation.thing +) + + +CONTACT_RESPONSE_LOADER_OPTIONS = ( + CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.emails), + CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.phones), + CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.addresses), + CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.incomplete_nma_phones), + CONTACT_RESPONSE_THING, + CONTACT_RESPONSE_PARTICIPANT.selectinload(Contact.notes), +) + + +SAMPLE_ACTIVITY_LOADER = joinedload(Sample.field_activity).joinedload( + FieldActivity.field_event +) + + +SAMPLE_LOADER_OPTIONS = ( + SAMPLE_ACTIVITY_LOADER, + *THING_RESPONSE_LOADER_OPTIONS, + *CONTACT_RESPONSE_LOADER_OPTIONS, +) + def get_db_samples( session: Session, @@ -12,15 +88,7 @@ def get_db_samples( sort: str | None = None, filter_: str | None = None, ): - query = session.query(Sample).options( - # Eagerly load related FieldActivity and FieldEvent to avoid N+1 problem - joinedload(Sample.field_activity) - .joinedload(FieldActivity.field_event) - .joinedload(FieldEvent.thing), - joinedload(Sample.field_event_participant).joinedload( - FieldEventParticipant.participant - ), # Eagerly load related Contact - ) + query = session.query(Sample).options(*SAMPLE_LOADER_OPTIONS) if thing_id: query = query.join(FieldActivity) @@ -30,3 +98,18 @@ def get_db_samples( query = order_sort_filter(query, Sample, sort, order, filter_) return paginate(query) + + +def get_sample_by_id_with_relationships( + session: Session, + sample_id: int, +) -> Sample: + sql = select(Sample).where(Sample.id == sample_id) + sql = sql.options(*SAMPLE_LOADER_OPTIONS) + sample = session.execute(sql).unique().scalar_one_or_none() + if sample is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"Sample with ID {sample_id} not found.", + ) + return sample diff --git a/services/scoped_transfer.py b/services/scoped_transfer.py new file mode 100644 index 00000000..39749de2 --- /dev/null +++ b/services/scoped_transfer.py @@ -0,0 +1,2003 @@ +from __future__ import annotations + +import io +import json +import logging +import re +import warnings +from contextlib import contextmanager +from dataclasses import asdict, dataclass, field +from functools import cached_property +from typing import Any, Callable + +import pandas as pd +from pandas.errors import DtypeWarning +from sqlalchemy import insert, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session +from starlette.datastructures import UploadFile + +from db import ( + Asset, + AssetThingAssociation, + Contact, + FieldActivity, + FieldEvent, + FieldEventParticipant, + Group, + GroupThingAssociation, + Location, + LocationThingAssociation, + NMA_Chemistry_SampleInfo, + Notes, + Observation, + PermissionHistory, + Sample, + Thing, + ThingContactAssociation, + ThingIdLink, +) +from db.engine import session_ctx +from services.asset_helper import upload_and_associate +from services.util import ( + get_county_from_point, + get_quad_name_from_point, + get_state_from_point, + retrieve_latest_polymorphic_history_table_record, +) +from transfers.asset_transfer import AssetTransferer +from transfers.associated_data import AssociatedDataTransferer +from transfers.chemistry_sampleinfo import ChemistrySampleInfoTransferer +from transfers.contact_transfer import ContactTransfer +from transfers.field_parameters_transfer import FieldParametersTransferer +from transfers.group_transfer import ProjectGroupTransferer +from transfers.hydraulicsdata import HydraulicsDataTransferer +from transfers.link_ids_transfer import ( + LinkIdsLocationDataTransferer, + LinkIdsWellDataTransferer, +) +from transfers.logger import logger +from transfers.major_chemistry import MajorChemistryTransferer +from transfers.minor_trace_chemistry_transfer import MinorTraceChemistryTransferer +from transfers.ngwmn_views import ( + NGWMNLithologyTransferer, + NGWMNWaterLevelsTransferer, + NGWMNWellConstructionTransferer, +) +from transfers.permissions_transfer import _make_permission +from transfers.radionuclides import RadionuclidesTransferer +from transfers.sensor_transfer import SensorTransferer +from transfers.soil_rock_results import SoilRockResultsTransferer +from transfers.stratigraphy_legacy import StratigraphyLegacyTransferer +from transfers.surface_water_data import SurfaceWaterDataTransferer +from transfers.surface_water_photos import SurfaceWaterPhotosTransferer +from transfers.thing_transfer import _release_status +from transfers.transferer import ChemistryTransferer, Transferer +from transfers.util import ( + filter_non_transferred_wells, + filter_by_valid_measuring_agency, + filter_to_valid_point_ids, + get_transferable_wells, + make_location, + make_location_data_provenance, + read_csv, + replace_nans, +) +from transfers.waterlevels_transfer import WaterLevelTransferer, get_contacts_info +from transfers.waterlevels_transducer_transfer import ( + WaterLevelsContinuousAcousticTransferer, + WaterLevelsContinuousPressureTransferer, +) +from transfers.waterlevelscontinuous_pressure_daily import ( + NMA_WaterLevelsContinuous_Pressure_DailyTransferer, +) +from transfers.weather_data import WeatherDataTransferer +from transfers.weather_photos import WeatherPhotosTransferer +from transfers.well_transfer import WellScreenTransferer, WellTransferer + + +class ScopedTransferError(RuntimeError): + pass + + +@dataclass(slots=True) +class ScopedTransferOptions: + pointids: list[str] + only: list[str] = field(default_factory=list) + skip: list[str] = field(default_factory=list) + dry_run: bool = False + + +@dataclass(slots=True) +class ScopedFamilyResult: + family: str + status: str + applicable_source_rows: int = 0 + created: int | None = None + skipped_existing: int | None = None + detail: str | None = None + added_as_prerequisite: bool = False + + +@dataclass(slots=True) +class ScopedTransferResult: + pointids: list[str] + selected_families: list[str] + added_prerequisites: list[str] + dry_run: bool + family_results: list[ScopedFamilyResult] + validation_errors: list[str] + execution_error: str | None = None + exit_code: int = 0 + + def to_payload(self) -> dict[str, Any]: + payload = asdict(self) + payload["family_results"] = [asdict(result) for result in self.family_results] + return payload + + +@dataclass(frozen=True, slots=True) +class FamilySpec: + name: str + planner: Callable[["ScopedTransferRuntime"], ScopedFamilyResult] + executor: Callable[["ScopedTransferRuntime"], ScopedFamilyResult] + dependencies: tuple[str, ...] = () + + +class ScopedTransferLogFilter(logging.Filter): + """Hide legacy transfer warnings that are misleading in scoped CLI mode.""" + + _suppressed_message_patterns = ( + re.compile(r"^\d+ PointIDs have duplicates; will skip\.$"), + re.compile(r"^Duplicate PointIDs: "), + re.compile(r"^Filtered out \d+ .+ without matching "), + re.compile(r"^No second contact info for PointID .+, skipping\.$"), + ) + + def filter(self, record: logging.LogRecord) -> bool: + message = record.getMessage() + return not any( + pattern.match(message) for pattern in self._suppressed_message_patterns + ) + + +@contextmanager +def _suppress_transfer_noise(): + """Temporarily reduce reused transfer-module logging to scoped-CLI signal only.""" + + root_logger = logging.getLogger() + previous_level = root_logger.level + previous_handler_levels = [handler.level for handler in root_logger.handlers] + scoped_filter = ScopedTransferLogFilter() + try: + root_logger.setLevel(logging.WARNING) + root_logger.addFilter(scoped_filter) + for handler in root_logger.handlers: + handler.setLevel(logging.WARNING) + handler.addFilter(scoped_filter) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DtypeWarning) + yield + finally: + root_logger.setLevel(previous_level) + root_logger.removeFilter(scoped_filter) + for handler, level in zip(root_logger.handlers, previous_handler_levels): + handler.setLevel(level) + handler.removeFilter(scoped_filter) + + +def normalize_pointids(pointids: list[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for raw in pointids: + value = (raw or "").strip().upper() + if not value or value in seen: + continue + seen.add(value) + normalized.append(value) + if not normalized: + raise ScopedTransferError("At least one --pointid value is required.") + return normalized + + +def _filter_requested_pointids( + df: pd.DataFrame | None, pointids: list[str] | None, column: str = "PointID" +) -> pd.DataFrame | None: + if df is None or not pointids or column not in df.columns: + return df + normalized = df[column].astype(str).str.strip().str.upper() + return df[normalized.isin(set(pointids))].copy() + + +def _matches_pointid_prefix(value: Any, pointids: list[str]) -> bool: + if value is None or pd.isna(value): + return False + text = str(value).strip().upper() + return any(text == pointid or text.startswith(f"{pointid}") for pointid in pointids) + + +class _PointIDFilteringMixin: + def _get_dfs(self): + input_df, cleaned_df = super()._get_dfs() + cleaned_df = _filter_requested_pointids(cleaned_df, self.pointids) + return input_df, cleaned_df + + +class ScopedWellScreenTransferer(_PointIDFilteringMixin, WellScreenTransferer): + pass + + +class ScopedWellTransferer(WellTransferer): + """Well transferer variant that applies PointID scoping before duplicate checks.""" + + def _get_dfs(self): + wdf = read_csv("WellData", dtype={"OSEWelltagID": str}) + ldf = read_csv("Location") + ldf = ldf.drop(["PointID", "SSMA_TimeStamp"], axis=1) + wdf = wdf.join(ldf.set_index("LocationId"), on="LocationId") + wdf = wdf[wdf["SiteType"] == "GW"] + wdf = wdf[wdf["Easting"].notna() & wdf["Northing"].notna()] + + input_df = wdf + wdf = replace_nans(wdf) + + cleaned_df = get_transferable_wells(wdf) + cleaned_df = filter_non_transferred_wells(cleaned_df) + # In scoped mode, duplicate warnings should only consider the requested subset. + cleaned_df = _filter_requested_pointids(cleaned_df, self.pointids) + + dupes = cleaned_df["PointID"].duplicated(keep=False) + if dupes.any(): + dup_ids = set(cleaned_df.loc[dupes, "PointID"]) + logger.critical(f"{len(dup_ids)} PointIDs have duplicates; will skip.") + logger.critical(f"Duplicate PointIDs: {dup_ids}") + cleaned_df = cleaned_df[~cleaned_df["PointID"].isin(dup_ids)] + + cleaned_df = cleaned_df.sort_values(by=["PointID"]) + return input_df, cleaned_df + + +class ScopedSensorTransferer(_PointIDFilteringMixin, SensorTransferer): + pass + + +class ScopedSurfaceWaterDataTransferer( + _PointIDFilteringMixin, SurfaceWaterDataTransferer +): + pass + + +class ScopedSurfaceWaterPhotosTransferer( + _PointIDFilteringMixin, SurfaceWaterPhotosTransferer +): + pass + + +class ScopedWeatherDataTransferer(_PointIDFilteringMixin, WeatherDataTransferer): + pass + + +class ScopedWeatherPhotosTransferer(_PointIDFilteringMixin, WeatherPhotosTransferer): + pass + + +class ScopedSoilRockResultsTransferer( + _PointIDFilteringMixin, SoilRockResultsTransferer +): + def _get_dfs(self): + input_df = self._read_csv(self.source_table) + cleaned_df = replace_nans(input_df) + cleaned_df = _filter_requested_pointids( + cleaned_df, self.pointids, column="Point_ID" + ) + return input_df, cleaned_df + + +class ScopedHydraulicsDataTransferer(_PointIDFilteringMixin, HydraulicsDataTransferer): + pass + + +class ScopedNGWMNWellConstructionTransferer( + _PointIDFilteringMixin, NGWMNWellConstructionTransferer +): + pass + + +class ScopedNGWMNWaterLevelsTransferer( + _PointIDFilteringMixin, NGWMNWaterLevelsTransferer +): + pass + + +class ScopedNGWMNLithologyTransferer(_PointIDFilteringMixin, NGWMNLithologyTransferer): + pass + + +class ScopedPressureDailyTransferer( + _PointIDFilteringMixin, NMA_WaterLevelsContinuous_Pressure_DailyTransferer +): + pass + + +class ScopedPressureTransferer( + _PointIDFilteringMixin, WaterLevelsContinuousPressureTransferer +): + pass + + +class ScopedAcousticTransferer( + _PointIDFilteringMixin, WaterLevelsContinuousAcousticTransferer +): + pass + + +class ScopedAssociatedDataTransferer(_PointIDFilteringMixin, AssociatedDataTransferer): + pass + + +class ScopedStratigraphyLegacyTransferer(StratigraphyLegacyTransferer): + pass + + +class ScopedChemistrySampleInfoTransferer(ChemistrySampleInfoTransferer): + def _build_thing_id_cache(self): + with session_ctx() as session: + query = ( + session.query( + Location.nma_pk_location, LocationThingAssociation.thing_id + ) + .join( + LocationThingAssociation, + Location.id == LocationThingAssociation.location_id, + ) + .join(Thing, Thing.id == LocationThingAssociation.thing_id) + .filter(Location.nma_pk_location.isnot(None)) + ) + if self.pointids: + query = query.filter(Thing.name.in_(self.pointids)) + + results = query.all() + location_to_thing = {} + for nma_pk_location, thing_id in results: + if nma_pk_location is None: + continue + location_to_thing[str(nma_pk_location).lower()] = thing_id + self._thing_id_cache = location_to_thing + + if not self._thing_id_cache: + logger.info("No matching Thing/Location rows found for ChemistrySampleInfo") + + +class _ScopedChemistryMixin(ChemistryTransferer): + def _build_sample_info_cache(self) -> None: + with session_ctx() as session: + query = ( + session.query( + NMA_Chemistry_SampleInfo.nma_sample_pt_id, + NMA_Chemistry_SampleInfo.id, + ) + .join(Thing, Thing.id == NMA_Chemistry_SampleInfo.thing_id) + .filter(NMA_Chemistry_SampleInfo.nma_sample_pt_id.isnot(None)) + ) + if self.pointids: + query = query.filter(Thing.name.in_(self.pointids)) + + sample_infos = query.all() + self._sample_info_cache = { + nma_sample_pt_id: csi_id for nma_sample_pt_id, csi_id in sample_infos + } + + +class ScopedFieldParametersTransferer(_ScopedChemistryMixin, FieldParametersTransferer): + pass + + +class ScopedMajorChemistryTransferer(_ScopedChemistryMixin, MajorChemistryTransferer): + pass + + +class ScopedMinorTraceChemistryTransferer( + _ScopedChemistryMixin, MinorTraceChemistryTransferer +): + pass + + +class ScopedRadionuclidesTransferer(_ScopedChemistryMixin, RadionuclidesTransferer): + pass + + +class ScopedProjectGroupTransferer(ProjectGroupTransferer): + def _step(self, session: Session, df: pd.DataFrame, i: int, row: pd.Series): + sql = select(Group).where(Group.name == row.Project) + group = session.scalars(sql).one_or_none() + if not group: + group = Group(name=row.Project) + + for prefix in row.PointIDPrefix.split(","): + prefix = prefix.strip() + if not prefix: + continue + + sql = select(Thing).where(Thing.name.like(f"{prefix}%")) + if self.pointids: + sql = sql.where(Thing.name.in_(self.pointids)) + records = session.scalars(sql).unique().all() + if not records: + continue + + existing_thing_ids = {assoc.thing_id for assoc in group.thing_associations} + group_is_monitoring_plan = False + for record in records: + if not group_is_monitoring_plan and record.status_history: + monitoring_status = [ + sh + for sh in record.status_history + if sh.status_type == "Monitoring Status" + ] + if monitoring_status: + monitoring_status = ( + retrieve_latest_polymorphic_history_table_record( + record, + "status_history", + "Monitoring Status", + ) + ) + if monitoring_status.status_value == "Currently monitored": + group_is_monitoring_plan = True + group.group_type = "Monitoring Plan" + + if record.id in existing_thing_ids: + continue + + gta = GroupThingAssociation(group=group, thing=record) + session.add(gta) + group.thing_associations.append(gta) + existing_thing_ids.add(record.id) + + session.add(group) + session.commit() + + +class ScopedAssetTransferer(AssetTransferer): + def _get_dfs(self): + input_df = read_csv(self.source_table) + cleaned_df = filter_to_valid_point_ids(input_df, self.pointids) + return input_df, cleaned_df + + def _transfer_hook(self, session: Session): + added_pointid = [] + for i, row in enumerate(self.cleaned_df.itertuples()): + if row.PointID in added_pointid: + continue + + added_pointid.append(row.PointID) + well = ( + session.query(Thing) + .filter(Thing.name == row.PointID, Thing.thing_type == "water well") + .one_or_none() + ) + if well is None: + self._capture_error(row.PointID, "Thing not found", "PointID") + continue + self._asset_step(session, i, well) + session.commit() + + def _asset_step(self, session, i, db_item): + df = self.cleaned_df + photos = df[df["PointID"] == db_item.name] + if photos.empty: + photos = df[df["PointID"] == db_item.name.replace("-", "")] + if photos.empty: + return + + existing_asset_names = { + name + for (name,) in session.query(Asset.name) + .join(AssetThingAssociation, AssetThingAssociation.asset_id == Asset.id) + .filter(AssetThingAssociation.thing_id == db_item.id) + .all() + if name + } + existing_asset_paths = { + storage_path + for (storage_path,) in session.query(Asset.storage_path) + .join(AssetThingAssociation, AssetThingAssociation.asset_id == Asset.id) + .filter(AssetThingAssociation.thing_id == db_item.id) + .all() + if storage_path + } + + for row in photos.itertuples(): + photo_path = row.OLEPath + srcblob = self._bucket.get_blob(f"nma-photos/{photo_path}") + if not srcblob: + self._capture_error( + db_item.name, f"No photo found for {photo_path}", "OLEPath" + ) + continue + + _, filename = srcblob.name.split("/") + if filename in existing_asset_names or any( + storage_path.endswith(filename) for storage_path in existing_asset_paths + ): + continue + + payload = srcblob.download_as_bytes() + upload = UploadFile( + file=io.BytesIO(payload), filename=filename, size=len(payload) + ) + uri = upload_and_associate( + session, + upload, + self._bucket, + db_item, + filename, + **{"label": filename, "mime_type": "image/png"}, + ) + existing_asset_names.add(filename) + if isinstance(uri, tuple) and len(uri) > 1: + existing_asset_paths.add(uri[1]) + + +class ScopedLinkIdsWellDataTransferer(LinkIdsWellDataTransferer): + def _get_dfs(self): + input_df = read_csv(self.source_table, self.source_dtypes) + wdf = replace_nans(input_df) + cleaned_df = filter_to_valid_point_ids(wdf, self.pointids) + return input_df, cleaned_df + + def _transfer_hook(self, session): + df = self._get_df_to_iterate() + for ci, chunk in enumerate(self._chunked_df(df)): + thing_id_by_pointid = { + name: thing_id + for name, thing_id in session.query(Thing.name, Thing.id) + .filter(Thing.name.in_(chunk.PointID.tolist())) + .all() + } + logger.info( + "Processing LinkIdsWellData chunk %s, %s rows, %s db items", + ci, + len(chunk), + len(thing_id_by_pointid), + ) + existing_link_keys = _fetch_existing_link_keys( + session, thing_id_by_pointid.values() + ) + + rows_to_insert: list[dict[str, Any]] = [] + for row in chunk.itertuples(index=False): + thing_id = thing_id_by_pointid.get(row.PointID) + if thing_id is None: + self._missing_db_item_warning(row) + continue + + if pd.isna(row.OSEWellID) and pd.isna(row.OSEWelltagID): + continue + + for aid, relation, regex in ( + (row.OSEWellID, "OSEPOD", self._ose_wellid_regex), + (row.OSEWelltagID, "OSEWellTagID", None), + ): + if pd.isna(aid): + continue + + aid_text = str(aid).strip() + if not aid_text or aid_text.casefold() in ("x", "?", "exempt"): + continue + + if regex and not regex.match(aid_text): + continue + + link_row = { + "thing_id": thing_id, + "relation": relation, + "alternate_id": aid_text, + "alternate_organization": "NMOSE", + } + link_key = _link_row_key(link_row) + if link_key in existing_link_keys: + continue + + rows_to_insert.append(link_row) + existing_link_keys.add(link_key) + + if rows_to_insert: + session.execute(insert(ThingIdLink), rows_to_insert) + session.commit() + session.expunge_all() + + def _chunked_df(self, df: pd.DataFrame): + chunk_size = getattr(self, "chunk_size", 1000) + for start in range(0, len(df), chunk_size): + yield df.iloc[start : start + chunk_size] + + +class ScopedLinkIdsLocationTransferer(LinkIdsLocationDataTransferer): + def _get_dfs(self): + input_df = read_csv( + self.source_table, + { + "SiteID": str, + "Township": str, + "TownshipDirection": str, + "Range": str, + "RangeDirection": str, + "SectionQuarters": str, + }, + ) + ldf = input_df[input_df["SiteType"] == self.site_type] + ldf = ldf[ldf["Easting"].notna() & ldf["Northing"].notna()] + ldf = replace_nans(ldf) + cleaned_df = filter_to_valid_point_ids(ldf, self.pointids) + return input_df, cleaned_df + + def _transfer_hook(self, session): + df = self._get_df_to_iterate() + for ci, chunk in enumerate(self._chunked_df(df)): + thing_id_by_pointid = { + name: thing_id + for name, thing_id in session.query(Thing.name, Thing.id) + .filter(Thing.name.in_(chunk.PointID.tolist())) + .all() + } + logger.info( + "Processing LinkIdsLocationData chunk %s, %s rows, %s db items", + ci, + len(chunk), + len(thing_id_by_pointid), + ) + existing_link_keys = _fetch_existing_link_keys( + session, thing_id_by_pointid.values() + ) + + rows_to_insert: list[dict[str, Any]] = [] + for row in chunk.itertuples(index=False): + thing_id = thing_id_by_pointid.get(row.PointID) + if thing_id is None: + self._missing_db_item_warning(row) + continue + + for func in ( + self._add_link_alternate_site_id, + self._add_link_site_id, + self._add_link_plss, + ): + link_row = func(row, thing_id) + if not link_row: + continue + link_key = _link_row_key(link_row) + if link_key in existing_link_keys: + continue + rows_to_insert.append(link_row) + existing_link_keys.add(link_key) + + if rows_to_insert: + session.execute(insert(ThingIdLink), rows_to_insert) + session.commit() + session.expunge_all() + + def _chunked_df(self, df: pd.DataFrame): + chunk_size = getattr(self, "chunk_size", 1000) + for start in range(0, len(df), chunk_size): + yield df.iloc[start : start + chunk_size] + + +class ScopedWaterLevelTransferer(WaterLevelTransferer): + """Scoped water-level transferer with rerun-safe contact and row handling.""" + + def _build_caches(self) -> None: + with session_ctx() as session: + thing_query = session.query(Thing.name, Thing.id) + if self.pointids: + thing_query = thing_query.filter(Thing.name.in_(self.pointids)) + self._thing_id_by_pointid = { + name: thing_id for name, thing_id in thing_query.all() + } + self._created_contact_id_by_key = { + (name, organization): contact_id + for name, organization, contact_id in session.query( + Contact.name, Contact.organization, Contact.id + ).all() + } + owner_query = ( + session.query(Thing.name, ThingContactAssociation.contact_id) + .join( + ThingContactAssociation, + Thing.id == ThingContactAssociation.thing_id, + ) + .order_by(Thing.name, ThingContactAssociation.id.asc()) + ) + if self.pointids: + owner_query = owner_query.filter(Thing.name.in_(self.pointids)) + owner_rows = owner_query.all() + owner_contact_cache: dict[str, int] = {} + for pointid, contact_id in owner_rows: + owner_contact_cache.setdefault(pointid, contact_id) + self._owner_contact_id_by_pointid = owner_contact_cache + + def _get_dfs(self) -> tuple[pd.DataFrame, pd.DataFrame]: + input_df = read_csv(self.source_table, dtype={"MeasuredBy": str}) + input_df = replace_nans(input_df) + cleaned_df = filter_to_valid_point_ids(input_df, self.pointids) + cleaned_df = filter_by_valid_measuring_agency(cleaned_df) + return input_df, cleaned_df + + def _lookup_existing_contact_ids( + self, session: Session, keys: list[tuple[str, str]] + ) -> dict[tuple[str, str], int]: + existing_contact_ids: dict[tuple[str, str], int] = {} + for name, organization in keys: + contact_id = ( + session.query(Contact.id) + .filter( + Contact.name == name, + Contact.organization == organization, + ) + .scalar() + ) + if contact_id is not None: + existing_contact_ids[(name, organization)] = contact_id + return existing_contact_ids + + def _get_field_event_participant_ids(self, session, row) -> list[int]: + self._last_contacts_created_count = 0 + self._last_contacts_reused_count = 0 + field_event_participant_ids: list[int] = [] + measured_by = None if pd.isna(row.MeasuredBy) else row.MeasuredBy + + if measured_by not in ["Owner", "Owner report", "Well owner"]: + if measured_by: + contact_info = get_contacts_info( + row, measured_by, self._measured_by_mapper + ) + contacts_to_create: list[dict[str, Any]] = [] + missing_keys: list[tuple[str, str]] = [] + for name, organization, role in contact_info: + key = (name, organization) + contact_id = self._created_contact_id_by_key.get(key) + if contact_id is not None: + field_event_participant_ids.append(contact_id) + self._last_contacts_reused_count += 1 + else: + contacts_to_create.append( + { + "name": name, + "role": role, + "contact_type": "Field Event Participant", + "organization": organization, + "nma_pk_waterlevels": row.GlobalID, + } + ) + missing_keys.append(key) + + if contacts_to_create: + try: + with session.begin_nested(): + created_contact_ids = ( + session.execute( + insert(Contact).returning(Contact.id), + contacts_to_create, + ) + .scalars() + .all() + ) + except Exception as e: + # Match the scoped reference branch behavior: if insert loses a race + # against an existing contact, reuse that contact instead of failing + # the whole water-level group. + logger.critical( + "Contact insert failed for PointID=%s, GlobalID=%s: %s", + row.PointID, + row.GlobalID, + str(e), + ) + existing_contact_ids = self._lookup_existing_contact_ids( + session, missing_keys + ) + unresolved_keys: list[tuple[str, str]] = [] + for key in missing_keys: + existing_contact_id = existing_contact_ids.get(key) + if existing_contact_id is None: + unresolved_keys.append(key) + continue + self._created_contact_id_by_key[key] = existing_contact_id + field_event_participant_ids.append(existing_contact_id) + self._last_contacts_reused_count += 1 + + if unresolved_keys: + logger.critical( + "Unable to resolve existing contact ids for PointID=%s, GlobalID=%s, keys=%s", + row.PointID, + row.GlobalID, + unresolved_keys, + ) + else: + for key, created_contact_id, payload in zip( + missing_keys, created_contact_ids, contacts_to_create + ): + self._created_contact_id_by_key[key] = created_contact_id + field_event_participant_ids.append(created_contact_id) + self._last_contacts_created_count += 1 + else: + owner_contact_id = self._owner_contact_id_by_pointid.get(row.PointID) + if owner_contact_id is None: + self._capture_error( + row.PointID, + "Thing has no contacts for owner fallback", + "MeasuredBy", + ) + else: + field_event_participant_ids.append(owner_contact_id) + self._last_contacts_reused_count += 1 + + return field_event_participant_ids + + def _transfer_hook(self, session: Session) -> None: + stats: dict[str, int] = { + "rows_skipped_existing": 0, + "field_events_created": 0, + "field_activities_created": 0, + "samples_created": 0, + "observations_created": 0, + } + + gwd = self.cleaned_df.groupby(["PointID"]) + for index, group in gwd: + pointid = index[0] + thing_id = self._thing_id_by_pointid.get(pointid) + if thing_id is None: + self._capture_error(pointid, "Thing not found", "PointID") + continue + + group_globalids = [ + str(global_id) + for global_id in group["GlobalID"].tolist() + if pd.notna(global_id) + ] + existing_globalids: set[str] = set() + if group_globalids: + existing_globalids.update( + global_id + for (global_id,) in session.query(Sample.nma_pk_waterlevels) + .filter(Sample.nma_pk_waterlevels.in_(group_globalids)) + .all() + if global_id + ) + existing_globalids.update( + global_id + for (global_id,) in session.query(Observation.nma_pk_waterlevels) + .filter(Observation.nma_pk_waterlevels.in_(group_globalids)) + .all() + if global_id + ) + + prepared_rows: list[dict[str, Any]] = [] + for row in group.itertuples(): + row_globalid = str(row.GlobalID) if pd.notna(row.GlobalID) else None + if row_globalid and row_globalid in existing_globalids: + # Scoped reruns should skip already-imported legacy water-level rows. + stats["rows_skipped_existing"] += 1 + continue + + dt_utc = self._get_dt_utc(row) + if dt_utc is None: + continue + try: + glv = self._get_groundwater_level_reason(row) + except (KeyError, ValueError) as exc: + self._capture_error( + row.PointID, + f"invalid groundwater level reason: {exc}", + "LevelStatus", + ) + continue + + release_status = "public" if row.PublicRelease else "private" + participant_ids = self._get_field_event_participant_ids(session, row) + is_destroyed = ( + glv + == "Well was destroyed (no subsequent water levels should be recorded)" + ) + prepared_rows.append( + { + "row": row, + "dt_utc": dt_utc, + "glv": glv, + "release_status": release_status, + "participant_ids": participant_ids, + "is_destroyed": is_destroyed, + } + ) + + for prep in prepared_rows: + field_event = FieldEvent( + thing_id=thing_id, + event_date=prep["dt_utc"], + release_status=prep["release_status"], + notes=prep["glv"] if prep["is_destroyed"] else None, + ) + session.add(field_event) + session.flush() + stats["field_events_created"] += 1 + + lead_participant = None + participants: list[FieldEventParticipant] = [] + for participant_idx, participant_id in enumerate( + prep["participant_ids"] + ): + participant = FieldEventParticipant( + field_event_id=field_event.id, + contact_id=participant_id, + participant_role=( + "Lead" if participant_idx == 0 else "Participant" + ), + release_status=prep["release_status"], + ) + session.add(participant) + participants.append(participant) + if participants: + session.flush() + lead_participant = participants[0] + + if prep["is_destroyed"]: + continue + + field_activity = FieldActivity( + field_event_id=field_event.id, + activity_type="groundwater level", + release_status=prep["release_status"], + ) + session.add(field_activity) + session.flush() + stats["field_activities_created"] += 1 + + sample = self._make_sample( + prep["row"], + field_activity, + prep["dt_utc"], + lead_participant, + ) + sample.release_status = prep["release_status"] + session.add(sample) + session.flush() + stats["samples_created"] += 1 + + observation = self._make_observation( + prep["row"], + sample, + prep["dt_utc"], + prep["glv"], + ) + observation.release_status = prep["release_status"] + session.add(observation) + stats["observations_created"] += 1 + + unique_notes: dict[tuple[str, Any], Any] = {} + for prep in prepared_rows: + site_notes = getattr(prep["row"], "SiteNotes", None) + if site_notes: + content = str(site_notes).strip() + if content: + dt = prep["dt_utc"] + key = (content, dt.date()) + if key not in unique_notes: + unique_notes[key] = dt + + for (content, _), dt in unique_notes.items(): + date_prefix = dt.strftime("%Y-%m-%d") + session.add( + Notes( + target_table="thing", + target_id=thing_id, + note_type="Site Notes (legacy)", + content=f"{date_prefix}: {content}", + release_status="public", + ) + ) + + try: + session.commit() + except IntegrityError: + session.rollback() + raise + + +def _fetch_existing_link_keys( + session: Session, thing_ids: list[int] | Any +) -> set[tuple[int, str, str, str]]: + thing_ids = list(thing_ids) + if not thing_ids: + return set() + return { + ( + thing_id, + relation, + alternate_id, + alternate_organization, + ) + for thing_id, relation, alternate_id, alternate_organization in session.query( + ThingIdLink.thing_id, + ThingIdLink.relation, + ThingIdLink.alternate_id, + ThingIdLink.alternate_organization, + ) + .filter(ThingIdLink.thing_id.in_(thing_ids)) + .all() + } + + +def _link_row_key(row: dict[str, Any]) -> tuple[int, str, str, str]: + return ( + int(row["thing_id"]), + str(row["relation"]), + str(row["alternate_id"]), + str(row["alternate_organization"]), + ) + + +class ScopedTransferRuntime: + def __init__(self, options: ScopedTransferOptions): + self.options = ScopedTransferOptions( + pointids=normalize_pointids(options.pointids), + only=list(options.only or []), + skip=list(options.skip or []), + dry_run=bool(options.dry_run), + ) + self._registry = build_family_registry() + + @cached_property + def selected_family_names(self) -> list[str]: + only = [name.strip() for name in self.options.only if name and name.strip()] + skip = [name.strip() for name in self.options.skip if name and name.strip()] + overlap = set(only) & set(skip) + if overlap: + names = ", ".join(sorted(overlap)) + raise ScopedTransferError( + f"Cannot use the same family in both --only and --skip: {names}" + ) + + if only: + selected = list(dict.fromkeys(only)) + else: + selected = list(DEFAULT_FAMILY_ORDER) + if skip: + selected = [name for name in selected if name not in set(skip)] + + unknown = [name for name in selected if name not in self._registry] + if unknown: + raise ScopedTransferError( + f"Unknown scoped-transfer family: {', '.join(sorted(unknown))}" + ) + + resolved: list[str] = [] + added: set[str] = set() + + def add_family(name: str): + if name in resolved: + return + for dep in self._registry[name].dependencies: + add_family(dep) + added.add(dep) + if name not in resolved: + resolved.append(name) + + for family in selected: + add_family(family) + self._added_prerequisites = sorted( + dep for dep in added if dep not in set(only or selected) + ) + return resolved + + @property + def added_prerequisites(self) -> list[str]: + _ = self.selected_family_names + return getattr(self, "_added_prerequisites", []) + + @property + def registry(self) -> dict[str, FamilySpec]: + return self._registry + + +DEFAULT_FAMILY_ORDER = [ + "wells", + "springs", + "perennial-streams", + "ephemeral-streams", + "met-stations", + "rock-sample-locations", + "diversion-of-surface-water", + "lake-pond-reservoir", + "soil-gas-sample-locations", + "other-site-types", + "outfall-wastewater-return-flow", + "screens", + "contacts", + "permissions", + "waterlevels", + "link-ids", + "groups", + "assets", + "associated-data", + "hydraulics-data", + "chemistry-sampleinfo", + "field-parameters", + "major-chemistry", + "radionuclides", + "minor-trace-chemistry", + "sensors", + "pressure", + "acoustic", + "pressure-daily", + "ngwmn-views", + "nma-stratigraphy", + "surface-water-data", + "surface-water-photos", + "weather-data", + "weather-photos", + "soil-rock-results", + "cleanup-locations", +] + + +def _run_transferer_class( + klass: type[Transferer], pointids: list[str] +) -> ScopedFamilyResult: + transferer = klass(pointids=pointids) + transferer.transfer() + applicable_rows = ( + len(transferer.cleaned_df) if transferer.cleaned_df is not None else 0 + ) + status = "completed" if applicable_rows else "no-op" + return ScopedFamilyResult( + family="", + status=status, + applicable_source_rows=applicable_rows, + ) + + +def _execute_wells(pointids: list[str]) -> ScopedFamilyResult: + transferer = ScopedWellTransferer(pointids=pointids) + # WellTransferer only supports the parallel entrypoint; run it with a + # single worker so the CLI stays effectively serial. + transferer.transfer_parallel(num_workers=1) + applicable_rows = ( + len(transferer.cleaned_df) if transferer.cleaned_df is not None else 0 + ) + status = "completed" if applicable_rows else "no-op" + return ScopedFamilyResult( + family="", + status=status, + applicable_source_rows=applicable_rows, + ) + + +def _plan_transferer_class( + klass: type[Transferer], pointids: list[str] +) -> ScopedFamilyResult: + transferer = klass(pointids=pointids) + _input_df, cleaned_df = transferer._get_dfs() + applicable_rows = len(cleaned_df) if cleaned_df is not None else 0 + status = "planned" if applicable_rows else "no-op" + return ScopedFamilyResult( + family="", + status=status, + applicable_source_rows=applicable_rows, + ) + + +def _plan_direct_pointid_table( + source_table: str, + pointids: list[str], + *, + site_type: str | None = None, + pointid_column: str = "PointID", +) -> ScopedFamilyResult: + df = read_csv(source_table) + if site_type and "SiteType" in df.columns: + df = df[df["SiteType"] == site_type] + filtered = _filter_requested_pointids(df, pointids, pointid_column) + count = len(filtered) if filtered is not None else 0 + return ScopedFamilyResult( + family="", + status="planned" if count else "no-op", + applicable_source_rows=count, + ) + + +def _plan_waterlevels(pointids: list[str]) -> ScopedFamilyResult: + df = read_csv("WaterLevels", dtype={"MeasuredBy": str}) + df = replace_nans(df) + df = _filter_requested_pointids(df, pointids) + df = filter_by_valid_measuring_agency(df) + count = len(df) if df is not None else 0 + return ScopedFamilyResult( + family="waterlevels", + status="planned" if count else "no-op", + applicable_source_rows=count, + ) + + +def _plan_sensors(pointids: list[str]) -> ScopedFamilyResult: + df = read_csv("Equipment") + if " " in "".join(df.columns.tolist()): + df.columns = df.columns.str.replace(" ", "_") + df = df[df["SerialNo"].notna()] + df = _filter_requested_pointids(df, pointids) + count = len(df) if df is not None else 0 + return ScopedFamilyResult( + family="sensors", + status="planned" if count else "no-op", + applicable_source_rows=count, + ) + + +def _plan_contacts(pointids: list[str]) -> ScopedFamilyResult: + owners_df = read_csv("OwnersData") + owner_link_df = read_csv("OwnerLink") + location_df = read_csv("Location") + owner_link_df = owner_link_df.join( + location_df.set_index("LocationId"), on="LocationId" + ) + owner_link_df = _filter_requested_pointids(owner_link_df, pointids) + if owner_link_df is None or owner_link_df.empty: + return ScopedFamilyResult( + family="contacts", + status="no-op", + applicable_source_rows=0, + ) + + owner_keys = ( + owner_link_df["OwnerKey"] + .dropna() + .astype(str) + .str.strip() + .str.casefold() + .unique() + ) + owners_df = owners_df.copy() + owner_key_column = next( + (column for column in owners_df.columns if column.lower() == "ownerkey"), + None, + ) + if owner_key_column is None: + return ScopedFamilyResult( + family="contacts", + status="no-op", + applicable_source_rows=0, + ) + normalized_owner_keys = ( + owners_df[owner_key_column].fillna("").astype(str).str.strip().str.casefold() + ) + owners_df = owners_df[normalized_owner_keys.isin(set(owner_keys))] + return ScopedFamilyResult( + family="contacts", + status="planned" if not owners_df.empty else "no-op", + applicable_source_rows=len(owners_df), + ) + + +def _plan_groups(pointids: list[str]) -> ScopedFamilyResult: + df = read_csv("Projects", {"Project": str, "PointIDPrefix": str}) + if df is None or df.empty: + return ScopedFamilyResult( + family="groups", + status="no-op", + applicable_source_rows=0, + ) + + matched = 0 + for row in df.itertuples(index=False): + prefixes = [ + prefix.strip().upper() for prefix in str(row.PointIDPrefix).split(",") + ] + if any( + any(pointid.startswith(prefix) for prefix in prefixes if prefix) + for pointid in pointids + ): + matched += 1 + + return ScopedFamilyResult( + family="groups", + status="planned" if matched else "no-op", + applicable_source_rows=matched, + ) + + +def _get_sample_point_ids_for_pointids(pointids: list[str]) -> set[str]: + df = read_csv("Chemistry_SampleInfo") + if df is None or df.empty: + return set() + sample_info_df = df[ + df["SamplePointID"].map(lambda value: _matches_pointid_prefix(value, pointids)) + ] + return { + str(value).strip().upper() + for value in sample_info_df["SamplePtID"].tolist() + if value is not None and pd.notna(value) + } + + +def _plan_chemistry_sampleinfo(pointids: list[str]) -> ScopedFamilyResult: + df = read_csv("Chemistry_SampleInfo") + if df is None or df.empty: + return ScopedFamilyResult( + family="chemistry-sampleinfo", + status="no-op", + applicable_source_rows=0, + ) + filtered = df[ + df["SamplePointID"].map(lambda value: _matches_pointid_prefix(value, pointids)) + ] + return ScopedFamilyResult( + family="chemistry-sampleinfo", + status="planned" if not filtered.empty else "no-op", + applicable_source_rows=len(filtered), + ) + + +def _plan_chemistry_child_table( + source_table: str, pointids: list[str] +) -> ScopedFamilyResult: + sample_pt_ids = _get_sample_point_ids_for_pointids(pointids) + if not sample_pt_ids: + return ScopedFamilyResult(family="", status="no-op", applicable_source_rows=0) + + df = read_csv(source_table) + if df is None or df.empty: + return ScopedFamilyResult(family="", status="no-op", applicable_source_rows=0) + + sample_ids = df["SamplePtID"].fillna("").astype(str).str.strip().str.upper() + filtered = df[sample_ids.isin(sample_pt_ids)] + return ScopedFamilyResult( + family="", + status="planned" if not filtered.empty else "no-op", + applicable_source_rows=len(filtered), + ) + + +def _plan_ngwmn_views(pointids: list[str]) -> ScopedFamilyResult: + total = 0 + for table_name in ( + "view_NGWMN_WaterLevels", + "view_NGWMN_WellConstruction", + "view_NGWMN_Lithology", + ): + df = read_csv(table_name) + filtered = _filter_requested_pointids(df, pointids) + total += len(filtered) if filtered is not None else 0 + return ScopedFamilyResult( + family="ngwmn-views", + status="planned" if total else "no-op", + applicable_source_rows=total, + ) + + +def _execute_permissions(pointids: list[str]) -> ScopedFamilyResult: + with session_ctx() as session: + wdf = read_csv("WellData", dtype={"OSEWelltagID": str}) + wdf = replace_nans(wdf) + wdf = _filter_requested_pointids(wdf, pointids) + + transferred_wells = ( + session.query(Thing, Contact) + .select_from(Thing) + .join(ThingContactAssociation, ThingContactAssociation.thing_id == Thing.id) + .join(Contact, Contact.id == ThingContactAssociation.contact_id) + .filter(Thing.thing_type == "water well") + .filter(Thing.name.in_(pointids)) + .order_by(Thing.name) + .all() + ) + + existing_permissions = { + (target_id, contact_id, permission_type) + for target_id, contact_id, permission_type in session.query( + PermissionHistory.target_id, + PermissionHistory.contact_id, + PermissionHistory.permission_type, + ) + .filter(PermissionHistory.target_table == "thing") + .all() + } + + created_count = 0 + skipped_existing = 0 + for thing, contact in transferred_wells: + for field_name, permission_type in ( + ("SampleOK", "Water Chemistry Sample"), + ("MonitorOK", "Water Level Sample"), + ): + permission = _make_permission( + wdf, thing, contact.id, field_name, permission_type + ) + if permission is None: + continue + key = (thing.id, contact.id, permission.permission_type) + if key in existing_permissions: + skipped_existing += 1 + continue + session.add(permission) + existing_permissions.add(key) + created_count += 1 + + session.commit() + return ScopedFamilyResult( + family="permissions", + status="completed" if transferred_wells else "no-op", + applicable_source_rows=len(transferred_wells), + created=created_count, + skipped_existing=skipped_existing, + ) + + +_THING_SITE_TYPE_SPECS: dict[str, tuple[str, str]] = { + "springs": ("SP", "spring"), + "perennial-streams": ("PS", "perennial stream"), + "ephemeral-streams": ("ES", "ephemeral stream"), + "met-stations": ("MS", "met station"), + "rock-sample-locations": ("R", "rock sample location"), + "diversion-of-surface-water": ("D", "diversion of surface water"), + "lake-pond-reservoir": ("L", "lake, pond, reservoir"), + "soil-gas-sample-locations": ("SG", "soil gas sample location"), + "other-site-types": ("O", "other site type"), + "outfall-wastewater-return-flow": ("OW", "outfall wastewater return flow"), +} + + +def _make_thing_payload_factory(thing_type: str): + def make_payload(row): + return { + "name": row.PointID, + "thing_type": thing_type, + "release_status": _release_status(row), + } + + return make_payload + + +def _plan_non_well_family(pointids: list[str], site_type: str) -> ScopedFamilyResult: + df = read_csv("Location") + df = replace_nans(df) + df = df[df["SiteType"] == site_type] + df = df[df["Easting"].notna() & df["Northing"].notna()] + df = _filter_requested_pointids(df, pointids) + count = len(df) + return ScopedFamilyResult( + family="", + status="planned" if count else "no-op", + applicable_source_rows=count, + ) + + +def _execute_non_well_family( + family: str, pointids: list[str], site_type: str, thing_type: str +) -> ScopedFamilyResult: + df = read_csv("Location") + df = replace_nans(df) + df = df[df["SiteType"] == site_type] + df = df[df["Easting"].notna() & df["Northing"].notna()] + df = _filter_requested_pointids(df, pointids) + + if df is None or df.empty: + return ScopedFamilyResult( + family=family, + status="no-op", + applicable_source_rows=0, + ) + + duplicate_mask = df["PointID"].duplicated(keep=False) + duplicate_pointids = set(df.loc[duplicate_mask, "PointID"]) + cached_elevations: dict[str, Any] = {} + payload_factory = _make_thing_payload_factory(thing_type) + created = 0 + skipped_existing = 0 + + with session_ctx() as session: + existing_names = { + name + for (name,) in session.query(Thing.name) + .filter(Thing.name.in_(df["PointID"].tolist())) + .all() + } + + for row in df.itertuples(index=False): + if row.PointID in duplicate_pointids: + continue + if row.PointID in existing_names: + skipped_existing += 1 + continue + + location, elevation_method, location_notes = make_location( + row, cached_elevations + ) + session.add(location) + session.flush() + payload = payload_factory(row) + thing = Thing( + name=payload["name"], + thing_type=payload["thing_type"], + release_status=payload["release_status"], + nma_pk_location=row.LocationId, + ) + session.add(thing) + session.flush() + session.add( + LocationThingAssociation(location_id=location.id, thing_id=thing.id) + ) + for note_type, note_content in location_notes.items(): + if pd.notna(note_content): + session.add( + Notes( + target_id=location.id, + target_table="location", + note_type=note_type, + content=note_content, + release_status="draft", + ) + ) + location_stub = type("LocationStub", (), {"id": location.id})() + for provenance in make_location_data_provenance( + row, location_stub, elevation_method + ): + session.add(provenance) + created += 1 + existing_names.add(row.PointID) + + session.commit() + + return ScopedFamilyResult( + family=family, + status="completed" if created or skipped_existing else "no-op", + applicable_source_rows=len(df), + created=created, + skipped_existing=skipped_existing, + ) + + +def _execute_cleanup_locations(pointids: list[str]) -> ScopedFamilyResult: + with session_ctx() as session: + locations = ( + session.query(Location) + .join( + LocationThingAssociation, + LocationThingAssociation.location_id == Location.id, + ) + .join(Thing, Thing.id == LocationThingAssociation.thing_id) + .filter(Thing.name.in_(pointids)) + .all() + ) + + updates = [] + for location in locations: + y, x = location.latlon + updates.append( + { + "id": location.id, + "state": location.state or get_state_from_point(x, y), + "county": location.county or get_county_from_point(x, y), + "quad_name": location.quad_name or get_quad_name_from_point(x, y), + } + ) + if updates: + session.bulk_update_mappings(Location, updates) + session.commit() + + return ScopedFamilyResult( + family="cleanup-locations", + status="completed" if updates else "no-op", + applicable_source_rows=len(locations), + created=len(updates), + ) + + +def build_family_registry() -> dict[str, FamilySpec]: + registry: dict[str, FamilySpec] = { + "wells": FamilySpec( + "wells", + planner=lambda rt: _plan_transferer_class( + ScopedWellTransferer, rt.options.pointids + ), + executor=lambda rt: _execute_wells(rt.options.pointids), + ), + "screens": FamilySpec( + "screens", + planner=lambda rt: _plan_transferer_class( + ScopedWellScreenTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedWellScreenTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "contacts": FamilySpec( + "contacts", + planner=lambda rt: _plan_contacts(rt.options.pointids), + executor=lambda rt: _run_transferer_class( + ContactTransfer, rt.options.pointids + ), + dependencies=("wells",), + ), + "permissions": FamilySpec( + "permissions", + planner=lambda rt: _plan_direct_pointid_table( + "WellData", rt.options.pointids + ), + executor=lambda rt: _execute_permissions(rt.options.pointids), + dependencies=("wells", "contacts"), + ), + "waterlevels": FamilySpec( + "waterlevels", + planner=lambda rt: _plan_waterlevels(rt.options.pointids), + executor=lambda rt: _run_transferer_class( + ScopedWaterLevelTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "pressure": FamilySpec( + "pressure", + planner=lambda rt: _plan_transferer_class( + ScopedPressureTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedPressureTransferer, rt.options.pointids + ), + dependencies=("wells", "sensors"), + ), + "acoustic": FamilySpec( + "acoustic", + planner=lambda rt: _plan_transferer_class( + ScopedAcousticTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedAcousticTransferer, rt.options.pointids + ), + dependencies=("wells", "sensors"), + ), + "pressure-daily": FamilySpec( + "pressure-daily", + planner=lambda rt: _plan_transferer_class( + ScopedPressureDailyTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedPressureDailyTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "sensors": FamilySpec( + "sensors", + planner=lambda rt: _plan_sensors(rt.options.pointids), + executor=lambda rt: _run_transferer_class( + ScopedSensorTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "groups": FamilySpec( + "groups", + planner=lambda rt: _plan_groups(rt.options.pointids), + executor=lambda rt: _run_transferer_class( + ScopedProjectGroupTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "assets": FamilySpec( + "assets", + planner=lambda rt: _plan_direct_pointid_table( + "WellPhotos", rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedAssetTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "associated-data": FamilySpec( + "associated-data", + planner=lambda rt: _plan_transferer_class( + ScopedAssociatedDataTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedAssociatedDataTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "hydraulics-data": FamilySpec( + "hydraulics-data", + planner=lambda rt: _plan_transferer_class( + ScopedHydraulicsDataTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedHydraulicsDataTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "chemistry-sampleinfo": FamilySpec( + "chemistry-sampleinfo", + planner=lambda rt: _plan_chemistry_sampleinfo(rt.options.pointids), + executor=lambda rt: _run_transferer_class( + ScopedChemistrySampleInfoTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "field-parameters": FamilySpec( + "field-parameters", + planner=lambda rt: _plan_chemistry_child_table( + "FieldParameters", rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedFieldParametersTransferer, rt.options.pointids + ), + dependencies=("chemistry-sampleinfo",), + ), + "major-chemistry": FamilySpec( + "major-chemistry", + planner=lambda rt: _plan_chemistry_child_table( + "MajorChemistry", rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedMajorChemistryTransferer, rt.options.pointids + ), + dependencies=("chemistry-sampleinfo",), + ), + "minor-trace-chemistry": FamilySpec( + "minor-trace-chemistry", + planner=lambda rt: _plan_chemistry_child_table( + "MinorandTraceChemistry", rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedMinorTraceChemistryTransferer, rt.options.pointids + ), + dependencies=("chemistry-sampleinfo",), + ), + "radionuclides": FamilySpec( + "radionuclides", + planner=lambda rt: _plan_chemistry_child_table( + "Radionuclides", rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedRadionuclidesTransferer, rt.options.pointids + ), + dependencies=("chemistry-sampleinfo",), + ), + "ngwmn-views": FamilySpec( + "ngwmn-views", + planner=lambda rt: _plan_ngwmn_views(rt.options.pointids), + executor=lambda rt: _execute_ngwmn_views(rt.options.pointids), + dependencies=("wells",), + ), + "nma-stratigraphy": FamilySpec( + "nma-stratigraphy", + planner=lambda rt: _plan_transferer_class( + ScopedStratigraphyLegacyTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedStratigraphyLegacyTransferer, rt.options.pointids + ), + dependencies=("wells",), + ), + "surface-water-data": FamilySpec( + "surface-water-data", + planner=lambda rt: _plan_transferer_class( + ScopedSurfaceWaterDataTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedSurfaceWaterDataTransferer, rt.options.pointids + ), + ), + "surface-water-photos": FamilySpec( + "surface-water-photos", + planner=lambda rt: _plan_transferer_class( + ScopedSurfaceWaterPhotosTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedSurfaceWaterPhotosTransferer, rt.options.pointids + ), + ), + "weather-data": FamilySpec( + "weather-data", + planner=lambda rt: _plan_transferer_class( + ScopedWeatherDataTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedWeatherDataTransferer, rt.options.pointids + ), + ), + "weather-photos": FamilySpec( + "weather-photos", + planner=lambda rt: _plan_transferer_class( + ScopedWeatherPhotosTransferer, rt.options.pointids + ), + executor=lambda rt: _run_transferer_class( + ScopedWeatherPhotosTransferer, rt.options.pointids + ), + ), + "soil-rock-results": FamilySpec( + "soil-rock-results", + planner=lambda rt: _plan_direct_pointid_table( + "Soil_Rock_Results", + rt.options.pointids, + pointid_column="Point_ID", + ), + executor=lambda rt: _run_transferer_class( + ScopedSoilRockResultsTransferer, rt.options.pointids + ), + ), + "cleanup-locations": FamilySpec( + "cleanup-locations", + planner=lambda rt: ScopedFamilyResult( + family="cleanup-locations", + status="planned", + applicable_source_rows=len(rt.options.pointids), + ), + executor=lambda rt: _execute_cleanup_locations(rt.options.pointids), + ), + "link-ids": FamilySpec( + "link-ids", + planner=lambda rt: _plan_direct_pointid_table( + "WellData", rt.options.pointids + ), + executor=lambda rt: _execute_link_ids(rt.options.pointids), + dependencies=("wells",), + ), + } + + for family_name, (site_type, thing_type) in _THING_SITE_TYPE_SPECS.items(): + registry[family_name] = FamilySpec( + family_name, + planner=lambda rt, st=site_type: _plan_non_well_family( + rt.options.pointids, st + ), + executor=lambda rt, fn=family_name, st=site_type, tt=thing_type: _execute_non_well_family( + fn, rt.options.pointids, st, tt + ), + ) + + return registry + + +def _execute_link_ids(pointids: list[str]) -> ScopedFamilyResult: + _run_transferer_class(ScopedLinkIdsWellDataTransferer, pointids) + _run_transferer_class(ScopedLinkIdsLocationTransferer, pointids) + well_df = _filter_requested_pointids( + replace_nans(read_csv("WellData", {"OSEWellID": str, "OSEWelltagID": str})), + pointids, + ) + location_df = _filter_requested_pointids( + replace_nans( + read_csv( + "Location", + { + "SiteID": str, + "Township": str, + "TownshipDirection": str, + "Range": str, + "RangeDirection": str, + "SectionQuarters": str, + }, + ) + ), + pointids, + ) + count = len(well_df) + len(location_df) + return ScopedFamilyResult( + family="link-ids", + status="completed" if count else "no-op", + applicable_source_rows=count, + ) + + +def _execute_ngwmn_views(pointids: list[str]) -> ScopedFamilyResult: + results = [ + _run_transferer_class(ScopedNGWMNWellConstructionTransferer, pointids), + _run_transferer_class(ScopedNGWMNWaterLevelsTransferer, pointids), + _run_transferer_class(ScopedNGWMNLithologyTransferer, pointids), + ] + count = sum(result.applicable_source_rows for result in results) + return ScopedFamilyResult( + family="ngwmn-views", + status="completed" if count else "no-op", + applicable_source_rows=count, + ) + + +def run_scoped_transfer(options: ScopedTransferOptions) -> ScopedTransferResult: + runtime = ScopedTransferRuntime(options) + + with _suppress_transfer_noise(): + plan_results: list[ScopedFamilyResult] = [] + matched_pointids: set[str] = set() + for family_name in runtime.selected_family_names: + spec = runtime.registry[family_name] + result = spec.planner(runtime) + result.family = family_name + result.added_as_prerequisite = family_name in runtime.added_prerequisites + plan_results.append(result) + + if result.applicable_source_rows: + if family_name in _THING_SITE_TYPE_SPECS: + site_type, _thing_type = _THING_SITE_TYPE_SPECS[family_name] + location_df = read_csv("Location") + location_df = replace_nans(location_df) + location_df = location_df[location_df["SiteType"] == site_type] + location_df = _filter_requested_pointids( + location_df, runtime.options.pointids + ) + matched_pointids.update( + location_df["PointID"] + .astype(str) + .str.strip() + .str.upper() + .tolist() + ) + elif family_name == "wells": + well_df = read_csv("WellData", dtype={"OSEWelltagID": str}) + well_df = replace_nans(well_df) + well_df = _filter_requested_pointids( + well_df, runtime.options.pointids + ) + matched_pointids.update( + well_df["PointID"].astype(str).str.strip().str.upper().tolist() + ) + else: + matched_pointids.update(runtime.options.pointids) + + missing = sorted(set(runtime.options.pointids) - matched_pointids) + if missing: + return ScopedTransferResult( + pointids=runtime.options.pointids, + selected_families=runtime.selected_family_names, + added_prerequisites=runtime.added_prerequisites, + dry_run=runtime.options.dry_run, + family_results=plan_results, + validation_errors=[ + "Requested PointIDs not found in applicable source data: " + + ", ".join(missing) + ], + exit_code=1, + ) + + if runtime.options.dry_run: + return ScopedTransferResult( + pointids=runtime.options.pointids, + selected_families=runtime.selected_family_names, + added_prerequisites=runtime.added_prerequisites, + dry_run=True, + family_results=plan_results, + validation_errors=[], + exit_code=0, + ) + + executed_results: list[ScopedFamilyResult] = [] + try: + for family_name in runtime.selected_family_names: + spec = runtime.registry[family_name] + result = spec.executor(runtime) + result.family = family_name + result.added_as_prerequisite = ( + family_name in runtime.added_prerequisites + ) + executed_results.append(result) + except Exception as exc: + return ScopedTransferResult( + pointids=runtime.options.pointids, + selected_families=runtime.selected_family_names, + added_prerequisites=runtime.added_prerequisites, + dry_run=False, + family_results=executed_results, + validation_errors=[], + execution_error=str(exc), + exit_code=1, + ) + + return ScopedTransferResult( + pointids=runtime.options.pointids, + selected_families=runtime.selected_family_names, + added_prerequisites=runtime.added_prerequisites, + dry_run=False, + family_results=executed_results, + validation_errors=[], + exit_code=0, + ) + + +def format_scoped_transfer_json(result: ScopedTransferResult) -> str: + return json.dumps(result.to_payload(), indent=2, default=str) diff --git a/services/thing_helper.py b/services/thing_helper.py index cc2fbf6e..652a893c 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -13,7 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +import logging +import time from datetime import datetime +from typing import Sequence, Optional from zoneinfo import ZoneInfo from fastapi import Request, HTTPException @@ -28,6 +31,7 @@ from db import ( LocationThingAssociation, Thing, + ThingContactAssociation, Location, WellScreen, WellPurpose, @@ -39,13 +43,22 @@ ThingIdLink, MonitoringFrequencyHistory, StatusHistory, + search, ) from services.audit_helper import audit_add from services.crud_helper import model_patcher from services.exceptions_helper import PydanticStyleException +from services.env import get_bool_env from services.geospatial_helper import make_within_wkt from services.query_helper import make_query, order_sort_filter, simple_get_by_id +logger = logging.getLogger(__name__) + + +def is_debug_timing_enabled() -> bool: + return bool(get_bool_env("API_DEBUG_TIMING", False)) + + WELL_DESCRIPTOR_MODEL_MAP = { "well_purposes": (WellPurpose, "purpose"), "well_casing_materials": (WellCasingMaterial, "material"), @@ -55,6 +68,9 @@ selectinload(Thing.location_associations).selectinload( LocationThingAssociation.location ), + selectinload(Thing.contact_associations).selectinload( + ThingContactAssociation.contact + ), selectinload(Thing.well_purposes), selectinload(Thing.well_casing_materials), selectinload(Thing.links), @@ -68,6 +84,26 @@ WATER_WELL_THING_TYPE = "water well" +def find_water_wells_by_name( + session: Session, + name: str, + *, + options: Sequence | None = None, +) -> list[Thing]: + sql = ( + select(Thing) + .where( + Thing.name == name, + Thing.thing_type == WATER_WELL_THING_TYPE, + ) + .order_by(Thing.id.asc()) + ) + if options: + sql = sql.options(*options) + + return session.scalars(sql).all() + + def wkb_to_geojson(wkb_element): if wkb_element is None: return None @@ -81,13 +117,18 @@ def get_db_things( query, session, sort, - thing_type: str = None, - within: str = None, - name: str = None, + thing_type: Optional[str] = None, + within: Optional[str] = None, + name: Optional[str] = None, + include_contacts: bool = False, ) -> list: if query: - sql = select(Thing).where(make_query(Thing, query)) + sql = search( + select(Thing), + query, + vector=Thing.search_vector, + ) else: sql = select(Thing) @@ -100,6 +141,13 @@ def get_db_things( # add all eager loads for generic thing query until/unless GET /thing is deprecated sql = sql.options(*WATER_WELL_LOADER_OPTIONS) + if include_contacts: + sql = sql.options( + selectinload(Thing.contact_associations).selectinload( + ThingContactAssociation.contact + ) + ) + if name: sql = sql.where(Thing.name == name) @@ -160,6 +208,7 @@ def verify_thing_type_correspondence(thing: Thing, thing_type: str): def get_thing_of_a_thing_type_by_id(session: Session, request: Request, thing_id: int): + started_at = time.perf_counter() thing_type = get_thing_type_from_request(request) sql = select(Thing).where(Thing.id == thing_id) @@ -175,6 +224,22 @@ def get_thing_of_a_thing_type_by_id(session: Session, request: Request, thing_id ) verify_thing_type_correspondence(thing, thing_type) + if is_debug_timing_enabled(): + duration_ms = round((time.perf_counter() - started_at) * 1000, 2) + logger.info( + "thing lookup completed path=%s thing_id=%s thing_type=%s duration_ms=%s", + request.url.path, + thing_id, + thing_type, + duration_ms, + extra={ + "event": "thing_lookup_completed", + "path": request.url.path, + "thing_id": thing_id, + "thing_type": thing_type, + "duration_ms": duration_ms, + }, + ) return thing @@ -221,6 +286,7 @@ def add_thing( datalogger_suitability_status = data.pop("is_suitable_for_datalogger", None) open_status = data.pop("is_open", None) well_status = data.pop("well_status", None) + monitoring_status = data.pop("monitoring_status", None) # ---------- # END UNIVERSAL THING RELATED TABLES @@ -361,6 +427,18 @@ def add_thing( audit_add(user, ws_status) session.add(ws_status) + if monitoring_status is not None: + ms_status = StatusHistory( + target_id=thing.id, + target_table="thing", + status_value=monitoring_status, + status_type="Monitoring Status", + start_date=effective_start, + end_date=None, + ) + audit_add(user, ms_status) + session.add(ms_status) + # ---------- # END WATER WELL SPECIFIC LOGIC # ---------- @@ -417,15 +495,16 @@ def add_thing( # ---------- if commit: session.commit() + session.refresh(thing) + + for note in thing.notes: + session.refresh(note) else: session.flush() - session.refresh(thing) - - for note in thing.notes: - session.refresh(note) except Exception as e: - session.rollback() + if commit: + session.rollback() raise e return thing diff --git a/services/util.py b/services/util.py index 7a3df7ee..374666e9 100644 --- a/services/util.py +++ b/services/util.py @@ -1,9 +1,9 @@ import json import logging -import os import time from datetime import datetime from zoneinfo import ZoneInfo + import httpx import pyproj from shapely.ops import transform @@ -47,22 +47,6 @@ def _get_json( return None -def to_bool(value: str) -> bool | str: - """Convert a string to a boolean.""" - if isinstance(value, bool): - return value - if value.lower() in ("true", "1", "yes"): - return True - elif value.lower() in ("false", "0", "no"): - return False - - return value - - -def get_bool_env(key, default=False): - return to_bool(os.getenv(key, default)) - - def transform_srid(geometry, source_srid, target_srid): """ geometry must be a shapely geometry object, like Point, Polygon, or MultiPolygon diff --git a/services/water_level_csv.py b/services/water_level_csv.py index f695fcd1..9faa5af2 100644 --- a/services/water_level_csv.py +++ b/services/water_level_csv.py @@ -19,7 +19,6 @@ import io import json import re -import uuid from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -27,43 +26,20 @@ from db import Thing, FieldEvent, FieldActivity, Sample, Observation, Parameter from db.engine import session_ctx -from pydantic import BaseModel, ConfigDict, ValidationError, field_validator +from pydantic import ValidationError +from schemas.water_level_csv import ( + WaterLevelCsvRow, + WATER_LEVEL_REQUIRED_FIELDS, + WATER_LEVEL_HEADER_ALIASES, + WATER_LEVEL_IGNORED_FIELDS, +) from sqlalchemy import select -from sqlalchemy.orm import Session - -# Required CSV columns for the bulk upload -REQUIRED_FIELDS: List[str] = [ - "field_staff", - "well_name_point_id", - "field_event_date_time", - "measurement_date_time", - "sampler", - "sample_method", - "mp_height", - "level_status", - "depth_to_water_ft", - "data_quality", -] - -HEADER_ALIASES: dict[str, str] = { - "measuring_person": "sampler", - "water_level_date_time": "measurement_date_time", -} - -# Allow-list values for validation. These represent early MVP lexicon values. -VALID_LEVEL_STATUSES = {"stable", "rising", "falling"} -VALID_DATA_QUALITIES = {"approved", "provisional"} -VALID_SAMPLERS = {"groundwater team", "consultant"} - -# Mapping between human-friendly sample methods provided in CSV uploads and -# their canonical lexicon terms stored in the database. -SAMPLE_METHOD_ALIASES = { - "electric tape": "Electric tape measurement (E-probe)", - "steel tape": "Steel-tape measurement", -} -SAMPLE_METHOD_CANONICAL = { - value.lower(): value for value in SAMPLE_METHOD_ALIASES.values() -} +from sqlalchemy.orm import Session, selectinload +from services.thing_helper import find_water_wells_by_name + +REQUIRED_FIELDS: List[str] = list(WATER_LEVEL_REQUIRED_FIELDS) +HEADER_ALIASES: dict[str, str] = dict(WATER_LEVEL_HEADER_ALIASES) +IGNORED_FIELDS: set[str] = set(WATER_LEVEL_IGNORED_FIELDS) @dataclass @@ -84,91 +60,16 @@ class _ValidatedRow: sample_method_term: str field_event_dt: datetime measurement_dt: datetime - mp_height: float - depth_to_water_ft: float - level_status: str - data_quality: str + mp_height: float | None + resolved_mp_height: float | int | None + existing_mp_height: float | int | None + mp_height_differs_from_history: bool + depth_to_water_ft: float | None + level_status: str | None + data_quality: str | None water_level_notes: str | None -class WaterLevelCsvRow(BaseModel): - model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) - - field_staff: str - well_name_point_id: str - field_event_date_time: datetime - measurement_date_time: datetime - sampler: str - sample_method: str - mp_height: float - level_status: str - depth_to_water_ft: float - data_quality: str - water_level_notes: str | None = None - - @field_validator( - "field_staff", - "well_name_point_id", - "sampler", - "sample_method", - "level_status", - "data_quality", - ) - @classmethod - def _require_value(cls, value: str) -> str: - if value is None or value == "": - raise ValueError("value is required") - return value - - @field_validator("sampler") - @classmethod - def _validate_sampler(cls, value: str) -> str: - if value.lower() not in VALID_SAMPLERS: - raise ValueError( - f"Invalid sampler '{value}'. Expected one of: {sorted(VALID_SAMPLERS)}" - ) - return value - - @field_validator("level_status") - @classmethod - def _validate_level_status(cls, value: str) -> str: - if value.lower() not in VALID_LEVEL_STATUSES: - raise ValueError( - f"Invalid level_status '{value}'. Expected one of: {sorted(VALID_LEVEL_STATUSES)}" - ) - return value - - @field_validator("data_quality") - @classmethod - def _validate_data_quality(cls, value: str) -> str: - if value.lower() not in VALID_DATA_QUALITIES: - raise ValueError( - f"Invalid data_quality '{value}'. Expected one of: {sorted(VALID_DATA_QUALITIES)}" - ) - return value - - @field_validator("sample_method") - @classmethod - def _normalize_sample_method(cls, value: str) -> str: - normalized = value.lower() - if normalized in SAMPLE_METHOD_ALIASES: - return SAMPLE_METHOD_ALIASES[normalized] - if normalized in SAMPLE_METHOD_CANONICAL: - return SAMPLE_METHOD_CANONICAL[normalized] - raise ValueError( - f"Invalid sample_method '{value}'. Expected one of: {sorted(SAMPLE_METHOD_ALIASES.keys())}" - ) - - @field_validator("water_level_notes", mode="before") - @classmethod - def _empty_to_none(cls, value: str | None) -> str | None: - if value is None: - return None - if isinstance(value, str) and value.strip() == "": - return None - return value - - def bulk_upload_water_levels( source_file: str | Path | bytes | BinaryIO, *, pretty_json: bool = False ) -> BulkUploadResult: @@ -180,15 +81,19 @@ def bulk_upload_water_levels( msg = f"File not found: {source_file}" payload = _build_payload([], [], 0, 0, 1, errors=[msg]) stdout = _serialize_payload(payload, pretty_json) - return BulkUploadResult(exit_code=1, stdout=stdout, stderr=msg, payload=payload) + return BulkUploadResult( + exit_code=1, + stdout=stdout, + stderr=msg, + payload=payload, + ) validation_errors: list[str] = [] created_rows: list[dict[str, Any]] = [] with session_ctx() as session: - parameter_id = _get_groundwater_level_parameter_id(session) - - # Validate headers early so we can short-circuit without touching the DB. + # Validate headers early so we can short-circuit + # without touching the DB. header_errors = _validate_headers(headers) if header_errors: validation_errors.extend(header_errors) @@ -196,20 +101,23 @@ def bulk_upload_water_levels( valid_rows, row_errors = _validate_rows(session, csv_rows) validation_errors.extend(row_errors) - if not validation_errors: + if valid_rows: try: - created_rows = _create_records(session, parameter_id, valid_rows) + parameter_id = _get_groundwater_level_parameter_id(session) + created_rows, persistence_errors = _create_records( + session, + parameter_id, + valid_rows, + ) + validation_errors.extend(persistence_errors) session.commit() except Exception as exc: # pragma: no cover - safety fallback session.rollback() validation_errors.append(str(exc)) - if validation_errors: - session.rollback() - summary = { "total_rows_processed": len(csv_rows), - "total_rows_imported": len(created_rows) if not validation_errors else 0, + "total_rows_imported": len(created_rows), "validation_errors_or_warnings": _count_rows_with_issues(validation_errors), } payload = _build_payload( @@ -217,7 +125,7 @@ def bulk_upload_water_levels( ) stdout = _serialize_payload(payload, pretty_json) stderr = "\n".join(validation_errors) - exit_code = 0 if not validation_errors else 1 + exit_code = 0 if created_rows or not validation_errors else 1 return BulkUploadResult( exit_code=exit_code, stdout=stdout, stderr=stderr, payload=payload ) @@ -288,16 +196,23 @@ def _read_csv( for k, v in row.items(): if k is None: continue - key = HEADER_ALIASES.get(k.strip(), k.strip()) + stripped_key = k.strip() + if stripped_key in IGNORED_FIELDS: + continue + key = HEADER_ALIASES.get(stripped_key, stripped_key) value = v.strip() if isinstance(v, str) else v or "" - # If both alias and canonical header are present, preserve first non-empty value. + # If both alias and canonical headers are present, keep the later + # non-empty value in CSV column order. An empty later value does not + # overwrite an earlier non-empty value. if key in normalized_row and normalized_row[key] and not value: continue normalized_row[key] = value rows.append(normalized_row) headers = [ - HEADER_ALIASES.get(h.strip(), h.strip()) for h in (reader.fieldnames or []) + HEADER_ALIASES.get(h.strip(), h.strip()) + for h in (reader.fieldnames or []) + if h is not None and h.strip() not in IGNORED_FIELDS ] return headers, rows @@ -337,24 +252,53 @@ def _validate_rows( well_name = model.well_name_point_id well = wells_by_name.get(well_name) if well is None: - sql = select(Thing).where(Thing.name == well_name) - well = session.scalars(sql).one_or_none() + matches = find_water_wells_by_name( + session, + well_name, + options=(selectinload(Thing.measuring_points),), + ) + if len(matches) > 1: + errors.append( + f"Row {idx}: Multiple wells found for well_name_point_id " + f"'{well_name}'" + ) + continue + well = matches[0] if matches else None if well is None: errors.append(f"Row {idx}: Unknown well_name_point_id '{well_name}'") continue wells_by_name[well_name] = well + ( + resolved_mp_height, + existing_mp_height, + mp_height_differs_from_history, + ) = _resolve_measuring_point_height(well, model.mp_height) + + depth_error = _validate_depth_to_water_against_well( + idx, + well, + model.depth_to_water_ft, + resolved_mp_height, + ) + if depth_error: + errors.append(depth_error) + continue + valid_rows.append( _ValidatedRow( row_index=idx, raw={**normalized}, well=well, field_staff=model.field_staff, - sampler=model.sampler, + sampler=model.measuring_person, sample_method_term=model.sample_method, field_event_dt=model.field_event_date_time, - measurement_dt=model.measurement_date_time, + measurement_dt=model.water_level_date_time, mp_height=model.mp_height, + resolved_mp_height=resolved_mp_height, + existing_mp_height=existing_mp_height, + mp_height_differs_from_history=mp_height_differs_from_history, depth_to_water_ft=model.depth_to_water_ft, level_status=model.level_status, data_quality=model.data_quality, @@ -365,61 +309,177 @@ def _validate_rows( return valid_rows, errors +def _resolve_measuring_point_height( + well: Thing, csv_mp_height: float | None +) -> tuple[float | int | None, float | int | None, bool]: + existing_mp_height = well.measuring_point_height + if existing_mp_height is not None: + existing_mp_height = float(existing_mp_height) + if csv_mp_height is not None: + return ( + csv_mp_height, + existing_mp_height, + (existing_mp_height is not None and csv_mp_height != existing_mp_height), + ) + + return existing_mp_height, existing_mp_height, False + + +def _validate_depth_to_water_against_well( + row_index: int, + well: Thing, + depth_to_water_ft: float | None, + resolved_mp_height: float | int | None, +) -> str | None: + well_depth = well.well_depth + if well_depth is not None: + well_depth = float(well_depth) + + if depth_to_water_ft is None or resolved_mp_height is None or well_depth is None: + return None + + corrected_depth_to_water = depth_to_water_ft - resolved_mp_height + if corrected_depth_to_water >= well_depth: + return ( + f"Row {row_index}: depth_to_water_ft minus measuring point height " + f"({corrected_depth_to_water}) must be less than well depth " + f"({well_depth})" + ) + + return None + + def _create_records( session: Session, parameter_id: int, rows: list[_ValidatedRow] -) -> list[dict[str, Any]]: +) -> tuple[list[dict[str, Any]], list[str]]: created: list[dict[str, Any]] = [] + errors: list[str] = [] for row in rows: - field_event = FieldEvent( - thing=row.well, - event_date=row.field_event_dt, - notes=_build_field_event_notes(row), - ) - field_activity = FieldActivity( - field_event=field_event, - activity_type="groundwater level", - notes=f"Sampler: {row.sampler}", - ) - sample = Sample( - field_activity=field_activity, - sample_date=row.measurement_dt, - sample_name=f"wl-{uuid.uuid4()}", - sample_matrix="water", - sample_method=row.sample_method_term, - qc_type="Normal", - notes=row.water_level_notes, - ) - observation = Observation( - sample=sample, - observation_datetime=row.measurement_dt, - parameter_id=parameter_id, - value=row.depth_to_water_ft, - unit="ft", - measuring_point_height=row.mp_height, - groundwater_level_reason=None, - notes=_build_observation_notes(row), + savepoint = None + try: + savepoint = session.begin_nested() + sample_name = _build_sample_name(row) + sample = _find_existing_imported_sample(session, row, sample_name) + + if sample is None: + field_event = FieldEvent( + thing=row.well, + event_date=row.field_event_dt, + notes=_build_field_event_notes(row), + ) + field_activity = FieldActivity( + field_event=field_event, + activity_type="groundwater level", + notes=f"Sampler: {row.sampler}", + ) + sample = Sample(field_activity=field_activity) + observation = Observation(sample=sample) + session.add(field_event) + session.add(field_activity) + session.add(sample) + session.add(observation) + else: + field_activity = sample.field_activity + field_event = field_activity.field_event + observation = _find_existing_observation(sample, parameter_id) + if observation is None: + observation = Observation(sample=sample) + session.add(observation) + + field_event.event_date = row.field_event_dt + field_event.notes = _build_field_event_notes(row) + field_activity.notes = f"Sampler: {row.sampler}" + + _apply_sample_values(sample, row, sample_name) + _apply_observation_values(observation, row, parameter_id) + session.flush() + savepoint.commit() + + if row.mp_height_differs_from_history: + errors.append( + "Row " + f"{row.row_index}: CSV mp_height ({row.mp_height}) differs " + "from existing measuring point height " + f"({row.existing_mp_height}); CSV value will be used" + ) + + created.append( + { + "well_name_point_id": row.raw["well_name_point_id"], + "field_event_id": field_event.id, + "field_activity_id": field_activity.id, + "sample_id": sample.id, + "observation_id": observation.id, + "measurement_date_time": row.raw.get("water_level_date_time"), + "level_status": row.level_status, + "data_quality": row.data_quality, + } + ) + except Exception as exc: # pragma: no cover - exercised via DB tests + if savepoint is not None and savepoint.is_active: + savepoint.rollback() + else: + session.expire_all() + errors.append(f"Row {row.row_index}: {exc}") + + return created, errors + + +def _build_sample_name(row: _ValidatedRow) -> str: + return f"{row.well.name}-WL-{row.measurement_dt.strftime('%Y%m%d%H%M')}" + + +def _find_existing_imported_sample( + session: Session, row: _ValidatedRow, sample_name: str +) -> Sample | None: + sql = ( + select(Sample) + .join(FieldActivity, Sample.field_activity_id == FieldActivity.id) + .join(FieldEvent, FieldActivity.field_event_id == FieldEvent.id) + .join(Thing, FieldEvent.thing_id == Thing.id) + .options( + selectinload(Sample.field_activity).selectinload(FieldActivity.field_event), + selectinload(Sample.observations), ) - session.add(field_event) - session.add(field_activity) - session.add(sample) - session.add(observation) - session.flush() - - created.append( - { - "well_name_point_id": row.raw["well_name_point_id"], - "field_event_id": field_event.id, - "field_activity_id": field_activity.id, - "sample_id": sample.id, - "observation_id": observation.id, - "measurement_date_time": row.raw["measurement_date_time"], - "level_status": row.level_status, - "data_quality": row.data_quality, - } + .where( + Thing.name == row.well.name, + Thing.thing_type == "water well", + FieldActivity.activity_type == "groundwater level", + Sample.sample_name == sample_name, ) + .order_by(Sample.id.asc()) + ) + return session.scalars(sql).first() + + +def _find_existing_observation(sample: Sample, parameter_id: int) -> Observation | None: + for observation in sample.observations: + if observation.parameter_id == parameter_id: + return observation + return None + + +def _apply_sample_values(sample: Sample, row: _ValidatedRow, sample_name: str) -> None: + sample.sample_date = row.measurement_dt + sample.sample_name = sample_name + sample.sample_matrix = "groundwater" + sample.sample_method = row.sample_method_term + sample.qc_type = "Normal" + sample.notes = row.water_level_notes - return created + +def _apply_observation_values( + observation: Observation, row: _ValidatedRow, parameter_id: int +) -> None: + observation.observation_datetime = row.measurement_dt + observation.parameter_id = parameter_id + observation.value = row.depth_to_water_ft + observation.unit = "ft" + observation.measuring_point_height = row.resolved_mp_height + observation.groundwater_level_reason = row.level_status + observation.nma_data_quality = row.data_quality + observation.notes = row.water_level_notes def _build_field_event_notes(row: _ValidatedRow) -> str | None: @@ -430,18 +490,24 @@ def _build_field_event_notes(row: _ValidatedRow) -> str | None: return notes or None -def _build_observation_notes(row: _ValidatedRow) -> str | None: - parts = [f"Level status: {row.level_status}", f"Data quality: {row.data_quality}"] - notes = " | ".join(parts) - return notes or None - - def _get_groundwater_level_parameter_id(session: Session) -> int: - sql = select(Parameter.id).where(Parameter.parameter_name == "groundwater level") + sql = select(Parameter.id).where( + Parameter.parameter_name == "groundwater level", + Parameter.matrix == "groundwater", + ) parameter_id = session.scalars(sql).one_or_none() - if parameter_id is None: - raise RuntimeError("Groundwater level parameter is not initialized") - return parameter_id + if parameter_id is not None: + return parameter_id + + parameter = Parameter( + parameter_name="groundwater level", + matrix="groundwater", + parameter_type="Field Parameter", + default_unit="ft", + ) + session.add(parameter) + session.flush() + return parameter.id # ============= EOF ============================================= diff --git a/services/well_details_helper.py b/services/well_details_helper.py new file mode 100644 index 00000000..7408d15a --- /dev/null +++ b/services/well_details_helper.py @@ -0,0 +1,250 @@ +import logging +import time + +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload, selectinload + +from db import ( + Contact, + Deployment, + FieldActivity, + FieldEvent, + FieldEventParticipant, + Observation, + Parameter, + Sample, + Sensor, + ThingContactAssociation, + WellScreen, +) +from services.env import get_bool_env +from services.thing_helper import get_thing_of_a_thing_type_by_id + +logger = logging.getLogger(__name__) + + +def is_debug_timing_enabled() -> bool: + return bool(get_bool_env("API_DEBUG_TIMING", False)) + + +def _log_payload_stage(payload_name: str, stage: str, thing_id: int, started_at: float): + if not is_debug_timing_enabled(): + return + duration_ms = round((time.perf_counter() - started_at) * 1000, 2) + logger.info( + "%s stage=%s thing_id=%s duration_ms=%s", + payload_name, + stage, + thing_id, + duration_ms, + extra={ + "event": "well_payload_stage_timing", + "payload_name": payload_name, + "stage": stage, + "thing_id": thing_id, + "duration_ms": duration_ms, + }, + ) + + +def get_well_details_payload( + session: Session, + request, + thing_id: int, + recent_observation_limit: int = 100, +): + payload_started_at = time.perf_counter() + stage_started_at = time.perf_counter() + well = get_thing_of_a_thing_type_by_id(session, request, thing_id) + _log_payload_stage("well_details", "load_well", thing_id, stage_started_at) + + stage_started_at = time.perf_counter() + contacts = session.scalars( + select(Contact) + .join(ThingContactAssociation) + .where(ThingContactAssociation.thing_id == well.id) + .options( + selectinload(Contact.emails), + selectinload(Contact.phones), + selectinload(Contact.addresses), + selectinload(Contact.incomplete_nma_phones), + selectinload(Contact.thing_associations).selectinload( + ThingContactAssociation.thing + ), + ) + .order_by(Contact.id) + ).all() + _log_payload_stage("well_details", "load_contacts", thing_id, stage_started_at) + + stage_started_at = time.perf_counter() + sensors = session.scalars( + select(Sensor) + .join(Deployment) + .where(Deployment.thing_id == well.id) + .distinct() + .order_by(Sensor.id) + ).all() + _log_payload_stage("well_details", "load_sensors", thing_id, stage_started_at) + + stage_started_at = time.perf_counter() + deployments = session.scalars( + select(Deployment) + .where(Deployment.thing_id == well.id) + .options(selectinload(Deployment.sensor)) + .order_by(Deployment.installation_date.desc(), Deployment.id.desc()) + ).all() + _log_payload_stage( + "well_details", + "load_deployments", + thing_id, + stage_started_at, + ) + + stage_started_at = time.perf_counter() + well_screens = session.scalars( + select(WellScreen) + .where(WellScreen.thing_id == well.id) + .order_by(WellScreen.screen_depth_top.asc(), WellScreen.id.asc()) + ).all() + _log_payload_stage( + "well_details", + "load_well_screens", + thing_id, + stage_started_at, + ) + + stage_started_at = time.perf_counter() + groundwater_parameter_id = ( + session.query(Parameter) + .filter(Parameter.parameter_name == "groundwater level") + .one() + .id + ) + _log_payload_stage( + "well_details", + "resolve_groundwater_parameter", + thing_id, + stage_started_at, + ) + + stage_started_at = time.perf_counter() + recent_groundwater_level_observations = session.scalars( + select(Observation) + .join(Sample) + .join(FieldActivity) + .join(FieldEvent) + .where( + FieldEvent.thing_id == well.id, + Observation.parameter_id == groundwater_parameter_id, + ) + .options(selectinload(Observation.parameter)) + .order_by(Observation.observation_datetime.desc(), Observation.id.desc()) + .limit(recent_observation_limit) + ).all() + _log_payload_stage( + "well_details", + "load_recent_groundwater_level_observations", + thing_id, + stage_started_at, + ) + + latest_field_event_sample = None + if recent_groundwater_level_observations: + latest_sample_id = recent_groundwater_level_observations[0].sample_id + stage_started_at = time.perf_counter() + latest_field_event_sample = session.scalar( + select(Sample) + .where(Sample.id == latest_sample_id) + .options( + joinedload(Sample.field_activity) + .joinedload(FieldActivity.field_event) + .joinedload(FieldEvent.thing), + joinedload(Sample.field_event_participant).joinedload( + FieldEventParticipant.participant + ), + ) + ) + _log_payload_stage( + "well_details", + "load_latest_field_event_sample", + thing_id, + stage_started_at, + ) + + _log_payload_stage( + "well_details", + "payload_total", + thing_id, + payload_started_at, + ) + + return { + "well": well, + "contacts": contacts, + "sensors": sensors, + "deployments": deployments, + "well_screens": well_screens, + "recent_groundwater_level_observations": recent_groundwater_level_observations, + "latest_field_event_sample": latest_field_event_sample, + } + + +def get_well_export_payload( + session: Session, + request, + thing_id: int, +): + payload_started_at = time.perf_counter() + stage_started_at = time.perf_counter() + well = get_thing_of_a_thing_type_by_id(session, request, thing_id) + _log_payload_stage("well_export", "load_well", thing_id, stage_started_at) + + stage_started_at = time.perf_counter() + contacts = session.scalars( + select(Contact) + .join(ThingContactAssociation) + .where(ThingContactAssociation.thing_id == well.id) + .options( + selectinload(Contact.emails), + selectinload(Contact.phones), + selectinload(Contact.addresses), + selectinload(Contact.incomplete_nma_phones), + selectinload(Contact.thing_associations).selectinload( + ThingContactAssociation.thing + ), + ) + .order_by(Contact.id) + ).all() + _log_payload_stage("well_export", "load_contacts", thing_id, stage_started_at) + + stage_started_at = time.perf_counter() + sensors = session.scalars( + select(Sensor) + .join(Deployment) + .where(Deployment.thing_id == well.id) + .distinct() + .order_by(Sensor.id) + ).all() + _log_payload_stage("well_export", "load_sensors", thing_id, stage_started_at) + + stage_started_at = time.perf_counter() + deployments = session.scalars( + select(Deployment) + .where(Deployment.thing_id == well.id) + .options(selectinload(Deployment.sensor)) + .order_by(Deployment.installation_date.desc(), Deployment.id.desc()) + ).all() + _log_payload_stage( + "well_export", + "load_deployments", + thing_id, + stage_started_at, + ) + _log_payload_stage("well_export", "payload_total", thing_id, payload_started_at) + + return { + "well": well, + "contacts": contacts, + "sensors": sensors, + "deployments": deployments, + } diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py index 247091a2..18e9a4f5 100644 --- a/services/well_inventory_csv.py +++ b/services/well_inventory_csv.py @@ -21,7 +21,7 @@ from datetime import date from io import StringIO from itertools import groupby -from typing import Set +from typing import Callable, Set from shapely import Point from sqlalchemy import select, and_ @@ -41,6 +41,9 @@ PermissionHistory, Thing, ThingContactAssociation, + Sample, + Observation, + Parameter, ) from db.engine import session_ctx from pydantic import ValidationError @@ -48,12 +51,15 @@ from schemas.well_inventory import WellInventoryRow from services.contact_helper import add_contact from services.exceptions_helper import PydanticStyleException -from services.thing_helper import add_thing +from services.thing_helper import add_thing, find_water_wells_by_name from services.util import transform_srid, convert_ft_to_m AUTOGEN_DEFAULT_PREFIX = "NM-" -AUTOGEN_PREFIX_REGEX = re.compile(r"^[A-Z]{2,3}-$") -AUTOGEN_TOKEN_REGEX = re.compile(r"^(?P[A-Z]{2,3})\s*-\s*(?:x{4}|X{4})$") +AUTOGEN_PREFIX_REGEX = re.compile(r"^[A-Z]{2,3}-$", re.IGNORECASE) +AUTOGEN_TOKEN_REGEX = re.compile( + r"^(?P[A-Z]{2,3})\s*-\s*(?:x{4}|X{4})$", re.IGNORECASE +) +PROGRESS_INTERVAL = 25 def _extract_autogen_prefix(well_id: str | None) -> str | None: @@ -84,10 +90,6 @@ def _extract_autogen_prefix(well_id: str | None) -> str | None: prefix = m.group("prefix").upper() return f"{prefix}-" - token_match = AUTOGEN_TOKEN_REGEX.match(value) - if token_match: - return f"{token_match.group('prefix')}-" - return None @@ -96,7 +98,19 @@ def import_well_inventory_csv(*args, **kw) -> dict: return _import_well_inventory_csv(session, *args, **kw) -def _import_well_inventory_csv(session: Session, text: str, user: str): +def _emit_progress( + progress_callback: Callable[[str], None] | None, message: str +) -> None: + if progress_callback is not None: + progress_callback(message) + + +def _import_well_inventory_csv( + session: Session, + text: str, + user: str, + progress_callback: Callable[[str], None] | None = None, +): # if not file.content_type.startswith("text/csv") or not file.filename.endswith( # ".csv" # ): @@ -143,13 +157,17 @@ def _import_well_inventory_csv(session: Session, text: str, user: str): raise ValueError("No data rows found") if len(rows) > 2000: raise ValueError(f"Too many rows {len(rows)}>2000") + _emit_progress( + progress_callback, f"Loaded {len(rows)} data rows. Validating input..." + ) try: header = text.splitlines()[0] dialect = csv.Sniffer().sniff(header) - except csv.Error: - # raise an error if sniffing fails, which likely means the header is not parseable as CSV - raise ValueError("Unable to parse CSV header") + except Exception: + # fallback to comma if sniffing fails + class dialect: + delimiter = "," if dialect.delimiter != ",": raise ValueError(f"Unsupported delimiter '{dialect.delimiter}'") @@ -159,74 +177,168 @@ def _import_well_inventory_csv(session: Session, text: str, user: str): duplicates = [col for col, count in counts.items() if count > 1] wells = [] + validation_errors = [] if duplicates: validation_errors = [ { - "row": 0, + "row": "header", "field": f"{duplicates}", "error": "Duplicate columns found", "value": duplicates, } ] + return { + "validation_errors": validation_errors, + "summary": { + "total_rows_processed": 0, + "total_rows_imported": 0, + "validation_errors_or_warnings": 1, + }, + "wells": [], + } - else: - models, validation_errors = _make_row_models(rows, session) - if models and not validation_errors: - current_row_id = None - try: - for project, items in groupby( - sorted(models, key=lambda x: x.project), key=lambda x: x.project - ): - # get project and add if does not exist - # BDMS-221 adds group_type - sql = select(Group).where( - and_( - Group.group_type == "Monitoring Plan", Group.name == project - ) - ) - group = session.scalars(sql).one_or_none() - if not group: - group = Group(name=project, group_type="Monitoring Plan") - session.add(group) - session.flush() - - for model in items: - current_row_id = model.well_name_point_id - added = _add_csv_row(session, group, model, user) - wells.append(added) - except ValueError as e: - validation_errors.append( - { - "row": current_row_id or "unknown", - "field": "Invalid value", - "error": str(e), - } - ) - session.rollback() - wells = [] - except DatabaseError as e: - logging.error( - f"Database error while importing row '{current_row_id or 'unknown'}': {e}" + try: + models, row_validation_errors = _make_row_models( + rows, session, progress_callback=progress_callback + ) + validation_errors.extend(row_validation_errors) + _emit_progress( + progress_callback, + ( + "Validation complete: " + f"{len(models)} rows ready to import, " + f"{len(row_validation_errors)} validation errors found." + ), + ) + + if models: + total_model_rows = len(models) + attempted_count = 0 + imported_count = 0 + # Group by project, preserving row number + # models is a list of (row_number, model) + sorted_models = sorted(models, key=lambda x: x[1].project) + for project, items in groupby(sorted_models, key=lambda x: x[1].project): + project_rows = list(items) + _emit_progress( + progress_callback, + ( + f"Importing project '{project}' " + f"({len(project_rows)} row{'s' if len(project_rows) != 1 else ''})..." + ), ) - validation_errors.append( - { - "row": current_row_id or "unknown", - "field": "Database error", - "error": "A database error occurred while importing this row.", - } + # Reuse an existing project group immediately, but defer creating a + # new one until a row for that project actually imports successfully. + sql = select(Group).where( + and_(Group.group_type == "Monitoring Plan", Group.name == project) ) - session.rollback() - wells = [] - else: - session.commit() + group = session.scalars(sql).one_or_none() + + for row_number, model in project_rows: + current_row_id = model.well_name_point_id + _emit_progress( + progress_callback, + ( + f"Starting row {attempted_count + 1}/{total_model_rows}: " + f"{current_row_id}" + ), + ) + try: + # Use savepoint for "best-effort" import per row + with session.begin_nested(): + group_for_row = group + if group_for_row is None: + group_for_row = Group( + name=project, group_type="Monitoring Plan" + ) + session.add(group_for_row) + session.flush() + + added = _add_csv_row(session, group_for_row, model, user) + if added: + wells.append(added) + group = group_for_row + imported_count += 1 + except ( + ValueError, + DatabaseError, + PydanticStyleException, + ValidationError, + ) as e: + if isinstance(e, PydanticStyleException): + error_text = str(e.detail) + field = "error" + elif isinstance(e, ValidationError): + # extract just the error messages + error_text = "; ".join( + [str(err.get("msg")) for err in e.errors()] + ) + field = _extract_field_from_value_error(error_text) + elif isinstance(e, DatabaseError): + error_text = str(getattr(e, "orig", None) or e) + error_text = " ".join(error_text.split()) + field = "Database error" + else: + error_text = str(e) + if error_text.startswith( + "Well already exists in database for well_name_point_id " + ): + field = "well_name_point_id" + else: + field = _extract_field_from_value_error(error_text) + + logging.error( + f"Error while importing row {row_number} ('{current_row_id}'): {error_text}" + ) + validation_errors.append( + { + "row": row_number, + "well_id": current_row_id, + "field": field, + "error": error_text, + } + ) + finally: + attempted_count += 1 + if ( + attempted_count == total_model_rows + or attempted_count % PROGRESS_INTERVAL == 0 + ): + _emit_progress( + progress_callback, + ( + "Import progress: " + f"{attempted_count}/{total_model_rows} validated rows attempted, " + f"{imported_count} imported, " + f"{len(validation_errors)} issues recorded." + ), + ) + session.commit() + else: + _emit_progress( + progress_callback, "No valid rows were available for import." + ) + except Exception as exc: + logging.exception("Unexpected error in _import_well_inventory_csv") + return {"detail": str(exc)} - rows_imported = len(wells) + wells_imported = [w for w in wells if w is not None] + rows_imported = len(wells_imported) rows_processed = len(rows) error_rows = { e.get("row") for e in validation_errors if e.get("row") not in (None, 0) } rows_with_validation_errors_or_warnings = len(error_rows) + _emit_progress( + progress_callback, + ( + "Import finished: " + f"{rows_imported}/{rows_processed} rows imported, " + f"{rows_with_validation_errors_or_warnings} rows with issues." + ), + ) + return { "validation_errors": validation_errors, "summary": { @@ -234,10 +346,20 @@ def _import_well_inventory_csv(session: Session, text: str, user: str): "total_rows_imported": rows_imported, "validation_errors_or_warnings": rows_with_validation_errors_or_warnings, }, - "wells": wells, + "wells": wells_imported, } +def _extract_field_from_value_error(error_text: str) -> str: + """Best-effort extraction of field name from wrapped validation errors.""" + lines = [line.strip() for line in error_text.splitlines() if line.strip()] + if len(lines) >= 3 and re.match(r"^\d+ validation error", lines[0]): + field_name = lines[1] + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", field_name): + return field_name + return "Invalid value" + + def _make_location(model) -> Location: point = Point(model.utm_easting, model.utm_northing) @@ -253,12 +375,21 @@ def _make_location(model) -> Location: transformed_point = transform_srid( point, source_srid=source_srid, target_srid=SRID_WGS84 ) - elevation_ft = float(model.elevation_ft) - elevation_m = convert_ft_to_m(elevation_ft) + elevation_ft = model.elevation_ft + elevation_m = ( + convert_ft_to_m(float(elevation_ft)) if elevation_ft is not None else 0.0 + ) + + release_status = "draft" + if model.public_availability_acknowledgement is True: + release_status = "public" + elif model.public_availability_acknowledgement is False: + release_status = "private" loc = Location( point=transformed_point.wkt, elevation=elevation_m, + release_status=release_status, ) return loc @@ -278,7 +409,8 @@ def _make_contact(model: WellInventoryRow, well: Thing, idx) -> dict: phones = [] addresses = [] name = getattr(model, f"contact_{idx}_name") - if name: + organization = getattr(model, f"contact_{idx}_organization") + if name or organization: for i in (1, 2): email = getattr(model, f"contact_{idx}_email_{i}") etype = getattr(model, f"contact_{idx}_email_{i}_type") @@ -306,13 +438,12 @@ def _make_contact(model: WellInventoryRow, well: Thing, idx) -> dict: "address_type": address_type, } ) - return { "thing_id": well.id, "name": name, - "organization": getattr(model, f"contact_{idx}_organization"), - "role": getattr(model, f"contact_{idx}_role"), - "contact_type": getattr(model, f"contact_{idx}_type"), + "organization": organization, + "role": getattr(model, f"contact_{idx}_role").value, + "contact_type": getattr(model, f"contact_{idx}_type").value, "emails": emails, "phones": phones, "addresses": addresses, @@ -382,12 +513,52 @@ def _generate_autogen_well_id(session, prefix: str, offset: int = 0) -> tuple[st return f"{prefix}{new_number:04d}", new_number -def _make_row_models(rows, session): +def _find_existing_imported_well( + session: Session, model: WellInventoryRow +) -> Thing | None: + if model.measurement_date_time is not None: + sample_name = ( + f"{model.well_name_point_id}-WL-" + f"{model.measurement_date_time.strftime('%Y%m%d%H%M')}" + ) + existing = session.scalars( + select(Thing) + .join(FieldEvent, FieldEvent.thing_id == Thing.id) + .join(FieldActivity, FieldActivity.field_event_id == FieldEvent.id) + .join(Sample, Sample.field_activity_id == FieldActivity.id) + .where( + Thing.name == model.well_name_point_id, + Thing.thing_type == "water well", + FieldActivity.activity_type == "groundwater level", + Sample.sample_name == sample_name, + ) + .order_by(Thing.id.asc()) + ).first() + if existing is not None: + return existing + + return session.scalars( + select(Thing) + .join(FieldEvent, FieldEvent.thing_id == Thing.id) + .join(FieldActivity, FieldActivity.field_event_id == FieldEvent.id) + .where( + Thing.name == model.well_name_point_id, + Thing.thing_type == "water well", + FieldEvent.event_date == model.date_time, + FieldActivity.activity_type == "well inventory", + ) + .order_by(Thing.id.asc()) + ).first() + + +def _make_row_models(rows, session, progress_callback=None): models = [] validation_errors = [] seen_ids: Set[str] = set() - offset = 0 + offsets = {} + total_rows = len(rows) for idx, row in enumerate(rows): + row_number = idx + 1 try: if all(key == row.get(key) for key in row.keys()): raise ValueError("Duplicate header row") @@ -397,10 +568,12 @@ def _make_row_models(rows, session): well_id = row.get("well_name_point_id") autogen_prefix = _extract_autogen_prefix(well_id) - if autogen_prefix: + if autogen_prefix is not None: + offset = offsets.get(autogen_prefix, 0) well_id, offset = _generate_autogen_well_id( session, autogen_prefix, offset ) + offsets[autogen_prefix] = offset row["well_name_point_id"] = well_id elif not well_id: raise ValueError("Field required") @@ -409,23 +582,24 @@ def _make_row_models(rows, session): raise ValueError("Duplicate value for well_name_point_id") seen_ids.add(well_id) - model = WellInventoryRow(**row) - models.append(model) - - except ValidationError as e: - for err in e.errors(): - loc = err["loc"] - - field = loc[0] if loc else "composite field error" - value = row.get(field) if loc else None - validation_errors.append( - { - "row": idx + 1, - "error": err["msg"], - "field": field, - "value": value, - } - ) + try: + model = WellInventoryRow(**row) + models.append((row_number, model)) + except ValidationError as e: + for err in e.errors(): + loc = err["loc"] + + field = loc[0] if loc else "composite field error" + value = row.get(field) if loc else None + validation_errors.append( + { + "row": row_number, + "well_id": well_id, + "error": err["msg"], + "field": field, + "value": value, + } + ) except ValueError as e: field = "well_name_point_id" # Map specific controlled errors to safe, non-revealing messages @@ -437,7 +611,7 @@ def _make_row_models(rows, session): error_msg = "Duplicate header row" field = "header" else: - error_msg = "Invalid value" + error_msg = str(e) if field == "header": value = ",".join(row.keys()) @@ -445,8 +619,25 @@ def _make_row_models(rows, session): value = row.get(field) validation_errors.append( - {"row": idx + 1, "field": field, "error": error_msg, "value": value} + { + "row": row_number, + "well_id": row.get("well_name_point_id"), + "field": field, + "error": error_msg, + "value": value, + } ) + finally: + if row_number == total_rows or row_number % PROGRESS_INTERVAL == 0: + _emit_progress( + progress_callback, + ( + "Validation progress: " + f"{row_number}/{total_rows} rows checked, " + f"{len(models)} valid, " + f"{len(validation_errors)} issues found." + ), + ) return models, validation_errors @@ -464,7 +655,7 @@ def _add_field_staff( if not contact: payload = dict(name=fs, role="Technician", organization=org, contact_type=ct) - contact = add_contact(session, payload, user) + contact = add_contact(session, payload, user, commit=False) fec = FieldEventParticipant( field_event=field_event, contact_id=contact.id, participant_role=role @@ -476,6 +667,16 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) name = model.well_name_point_id date_time = model.date_time + existing_well = _find_existing_imported_well(session, model) + if existing_well is not None: + return existing_well.name + + existing_named_wells = find_water_wells_by_name(session, model.well_name_point_id) + if existing_named_wells: + raise ValueError( + f"Well already exists in database for well_name_point_id '{model.well_name_point_id}'" + ) + # -------------------- # Location and associated tables # -------------------- @@ -493,11 +694,16 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) session.add(directions_note) # add data provenance records + elevation_method = ( + model.elevation_method.value + if hasattr(model.elevation_method, "value") + else (model.elevation_method or "Unknown") + ) dp = DataProvenance( target_id=loc.id, target_table="location", field_name="elevation", - collection_method=model.elevation_method, + collection_method=elevation_method, ) session.add(dp) @@ -513,7 +719,11 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) She indicated that it would be acceptable to use the depth source for the historic depth to water source. """ if model.depth_source: - historic_depth_to_water_source = model.depth_source.lower() + historic_depth_to_water_source = ( + model.depth_source.value + if hasattr(model.depth_source, "value") + else model.depth_source + ).lower() else: historic_depth_to_water_source = "unknown" @@ -528,7 +738,17 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) (model.contact_special_requests_notes, "General"), (model.well_measuring_notes, "Sampling Procedure"), (model.sampling_scenario_notes, "Sampling Procedure"), + (model.well_notes, "General"), + (model.water_notes, "Water"), (historic_depth_note, "Historical"), + ( + ( + f"Sample possible: {model.sample_possible}" + if model.sample_possible is not None + else None + ), + "Sampling Procedure", + ), ): if note_content is not None: well_notes.append({"content": note_content, "note_type": note_type}) @@ -563,6 +783,22 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) } ) + if ( + model.mp_height is not None + and model.measuring_point_height_ft is not None + and model.mp_height != model.measuring_point_height_ft + ): + raise ValueError( + "Conflicting values for measuring point height: mp_height and measuring_point_height_ft" + ) + + if model.measuring_point_height_ft is not None: + universal_mp_height = model.measuring_point_height_ft + elif model.mp_height is not None: + universal_mp_height = model.mp_height + else: + universal_mp_height = None + data = CreateWell( location_id=loc.id, group_id=group.id, @@ -571,7 +807,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) well_depth=model.total_well_depth_ft, well_depth_source=model.depth_source, well_casing_diameter=model.casing_diameter_ft, - measuring_point_height=model.measuring_point_height_ft, + measuring_point_height=universal_mp_height, measuring_point_description=model.measuring_point_description, well_completion_date=model.date_drilled, well_completion_date_source=model.completion_source, @@ -579,7 +815,16 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) well_pump_depth=model.well_pump_depth_ft, is_suitable_for_datalogger=model.datalogger_possible, is_open=model.is_open, - well_status=model.well_status, + well_status=( + model.well_status.value + if hasattr(model.well_status, "value") + else model.well_status + ), + monitoring_status=( + model.monitoring_status.value + if hasattr(model.monitoring_status, "value") + else model.monitoring_status + ), notes=well_notes, well_purposes=well_purposes, monitoring_frequencies=monitoring_frequencies, @@ -650,6 +895,77 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) ) session.add(fa) + if model.measurement_date_time is not None: + # get groundwater level parameter + parameter = ( + session.query(Parameter) + .filter( + Parameter.parameter_name == "groundwater level", + Parameter.matrix == "groundwater", + ) + .first() + ) + + if not parameter: + # this shouldn't happen if initialized properly, but just in case + parameter = Parameter( + parameter_name="groundwater level", + matrix="groundwater", + parameter_type="Field Parameter", + default_unit="ft", + ) + session.add(parameter) + session.flush() + + # create FieldActivity + gwl_field_activity = FieldActivity( + field_event=fe, + activity_type="groundwater level", + notes="Groundwater level measurement activity conducted during well inventory field event.", + ) + session.add(gwl_field_activity) + session.flush() + + # create Sample + sample_method = ( + model.sample_method.value + if hasattr(model.sample_method, "value") + else (model.sample_method or "Unknown") + ) + sample = Sample( + field_activity_id=gwl_field_activity.id, + sample_date=model.measurement_date_time, + sample_name=f"{well.name}-WL-{model.measurement_date_time.strftime('%Y%m%d%H%M')}", + sample_matrix="groundwater", + sample_method=sample_method, + notes=model.water_level_notes, + ) + session.add(sample) + session.flush() + + # create Observation + # TODO: groundwater_level_reason may be conditionally required for null depth_to_water_ft - handle accordingly + observation = Observation( + sample_id=sample.id, + parameter_id=parameter.id, + value=model.depth_to_water_ft, + unit="ft", + observation_datetime=model.measurement_date_time, + measuring_point_height=universal_mp_height, + groundwater_level_reason=( + model.level_status.value + if hasattr(model.level_status, "value") + else None + ), + nma_data_quality=( + model.data_quality.value + if hasattr(model.data_quality, "value") + else model.data_quality or None + ), + notes=model.water_level_notes, + ) + session.add(observation) + # ------------------ # Contacts # ------------------ diff --git a/tests/__init__.py b/tests/__init__.py index b5cee011..57fa0c35 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -14,13 +14,29 @@ # limitations under the License. # =============================================================================== import os +import socket from functools import lru_cache from dotenv import load_dotenv # Load .env file BEFORE importing anything else -# Use override=True to override conflicting shell environment variables -load_dotenv(override=True) +# Use override=False so explicit shell environment variables can override .env +load_dotenv(override=False) + + +def _normalize_test_db_host() -> None: + """Fallback docker-compose hostnames to localhost for host-run tests.""" + for env_name in ("POSTGRES_HOST", "PYGEOAPI_POSTGRES_HOST"): + host = (os.environ.get(env_name) or "").strip() + if host != "db": + continue + try: + socket.gethostbyname(host) + except OSError: + os.environ[env_name] = "localhost" + + +_normalize_test_db_host() # for safety don't test on the production database port os.environ["POSTGRES_PORT"] = "5432" diff --git a/tests/conftest.py b/tests/conftest.py index 3847263b..5818707b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,16 @@ import os +import socket import pytest from alembic import command from alembic.config import Config from dotenv import load_dotenv +from sqlalchemy import delete +from sqlalchemy import inspect as sa_inspect from core.initializers import init_lexicon, init_parameter from db import * -from db.engine import session_ctx +from db.engine import engine, session_ctx from db.initialization import ( recreate_public_schema, sync_search_vector_triggers, @@ -16,7 +19,15 @@ def pytest_configure(): - load_dotenv(override=True) + load_dotenv(override=False) + for env_name in ("POSTGRES_HOST", "PYGEOAPI_POSTGRES_HOST"): + host = (os.environ.get(env_name) or "").strip() + if host != "db": + continue + try: + socket.gethostbyname(host) + except OSError: + os.environ[env_name] = "localhost" os.environ.setdefault("POSTGRES_PORT", "54321") # NOTE: This hardcoded secret key is for tests only and must NEVER be used in production. os.environ.setdefault("SESSION_SECRET_KEY", "test-session-secret-key") @@ -32,13 +43,31 @@ def _alembic_config() -> Config: def _reset_schema() -> None: + engine.dispose() with session_ctx() as session: recreate_public_schema(session) + engine.dispose() def _sync_search_vectors() -> None: + engine.dispose() with session_ctx() as session: sync_search_vector_triggers(session) + engine.dispose() + + +def _delete_if_present(session, obj) -> None: + if obj is None: + return + + state = sa_inspect(obj) + identity = state.identity + if identity is None: + return + + persistent = session.get(type(obj), identity[0] if len(identity) == 1 else identity) + if persistent is not None: + session.delete(persistent) @pytest.fixture(scope="session", autouse=True) @@ -46,6 +75,7 @@ def _setup_test_db(): """Reset schema once per session; tests share DB state, so keep isolation in fixtures.""" _reset_schema() command.upgrade(_alembic_config(), "head") + engine.dispose() _sync_search_vectors() init_lexicon() init_parameter() @@ -103,7 +133,10 @@ def location(): loc = Location( point="POINT(-107.949533 33.809665)", elevation=2464.9, + county="Sierra", release_status="draft", + state="NM", + quad_name="Hillsboro Peak", ) session.add(loc) @@ -114,11 +147,11 @@ def location(): session.add(note) session.commit() session.refresh(loc) + location_id = loc.id yield loc - session.delete(note) - session.delete(loc) + session.execute(delete(Location).where(Location.id == location_id)) session.commit() @@ -132,8 +165,9 @@ def second_location(): ) session.add(location) session.commit() + location_id = location.id yield location - session.delete(location) + session.execute(delete(Location).where(Location.id == location_id)) session.commit() @@ -272,8 +306,9 @@ def second_well_screen(water_well_thing): ) session.add(screen) session.commit() + screen_id = screen.id yield screen - session.delete(screen) + session.execute(delete(WellScreen).where(WellScreen.id == screen_id)) session.commit() @@ -289,8 +324,9 @@ def thing_id_link(water_well_thing): ) session.add(id_link) session.commit() + link_id = id_link.id yield id_link - session.delete(id_link) + session.execute(delete(ThingIdLink).where(ThingIdLink.id == link_id)) session.commit() @@ -306,8 +342,9 @@ def second_thing_id_link(water_well_thing): ) session.add(id_link) session.commit() + link_id = id_link.id yield id_link - session.delete(id_link) + session.execute(delete(ThingIdLink).where(ThingIdLink.id == link_id)) session.commit() @@ -330,9 +367,14 @@ def spring_thing(location): assoc.thing_id = spring.id session.add(assoc) session.commit() + spring_id = spring.id yield spring - session.delete(spring) - session.delete(assoc) + session.execute( + delete(LocationThingAssociation).where( + LocationThingAssociation.thing_id == spring_id + ) + ) + session.execute(delete(Thing).where(Thing.id == spring_id)) session.commit() @@ -355,9 +397,14 @@ def second_spring_thing(location): assoc.thing_id = spring.id session.add(assoc) session.commit() + spring_id = spring.id yield spring - session.delete(spring) - session.delete(assoc) + session.execute( + delete(LocationThingAssociation).where( + LocationThingAssociation.thing_id == spring_id + ) + ) + session.execute(delete(Thing).where(Thing.id == spring_id)) session.commit() @@ -377,8 +424,9 @@ def sensor(): ) session.add(sensor) session.commit() + sensor_id = sensor.id yield sensor - session.delete(sensor) + session.execute(delete(Sensor).where(Sensor.id == sensor_id)) session.commit() @@ -398,8 +446,9 @@ def second_sensor(): ) session.add(sensor) session.commit() + sensor_id = sensor.id yield sensor - session.delete(sensor) + session.execute(delete(Sensor).where(Sensor.id == sensor_id)) session.commit() @@ -688,8 +737,13 @@ def asset_with_associated_thing(water_well_thing): session.refresh(association) yield asset - session.delete(asset) - session.delete(association) + session.execute( + delete(AssetThingAssociation).where( + AssetThingAssociation.asset_id == asset.id, + AssetThingAssociation.thing_id == water_well_thing.id, + ) + ) + session.execute(delete(Asset).where(Asset.id == asset.id)) session.commit() @@ -709,7 +763,7 @@ def second_asset(): session.commit() session.refresh(asset) yield asset - session.delete(asset) + session.execute(delete(Asset).where(Asset.id == asset.id)) session.commit() diff --git a/tests/features/data/well-inventory-duplicate-columns.csv b/tests/features/data/well-inventory-duplicate-columns.csv index cf459663..4f743a19 100644 --- a/tests/features/data/well-inventory-duplicate-columns.csv +++ b/tests/features/data/well-inventory-duplicate-columns.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible,contact_1_email_1 -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True,john.smith@example.com -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False,emily.davis@example.org +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True,john.smith@example.com +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False,emily.davis@example.org diff --git a/tests/features/data/well-inventory-duplicate-header.csv b/tests/features/data/well-inventory-duplicate-header.csv index 40c35980..698fc335 100644 --- a/tests/features/data/well-inventory-duplicate-header.csv +++ b/tests/features/data/well-inventory-duplicate-header.csv @@ -1,5 +1,5 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1f,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True \ No newline at end of file +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1f,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True \ No newline at end of file diff --git a/tests/features/data/well-inventory-duplicate.csv b/tests/features/data/well-inventory-duplicate.csv index 4f8ac75a..514cd6d3 100644 --- a/tests/features/data/well-inventory-duplicate.csv +++ b/tests/features/data/well-inventory-duplicate.csv @@ -1,3 +1,3 @@ project,measuring_point_height_ft,well_name_point_id,site_name,date_time,field_staff,contact_role,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method -foo,10,WELL001,Site Alpha,2025-02-15T10:30:00,Jane Doe,Owner,250000,4000000,13N,5120.5,LiDAR DEM -foob,10,WELL001,Site Beta,2025-03-20T09:15:00,John Smith,Manager,250000,4000000,13N,5130.7,LiDAR DEM +foo,10,DUPWELL001,Site Alpha,2025-02-15T10:30:00,Jane Doe,Owner,250000,4000000,13N,5120.5,LiDAR DEM +foob,10,DUPWELL001,Site Beta,2025-03-20T09:15:00,John Smith,Manager,250000,4000000,13N,5130.7,LiDAR DEM diff --git a/tests/features/data/well-inventory-invalid-boolean-value-maybe.csv b/tests/features/data/well-inventory-invalid-boolean-value-maybe.csv index 75f3a33e..70d5a7a6 100644 --- a/tests/features/data/well-inventory-invalid-boolean-value-maybe.csv +++ b/tests/features/data/well-inventory-invalid-boolean-value-maybe.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,maybe,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,maybe,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-invalid-contact-type.csv b/tests/features/data/well-inventory-invalid-contact-type.csv index f06f5b3b..236e5e03 100644 --- a/tests/features/data/well-inventory-invalid-contact-type.csv +++ b/tests/features/data/well-inventory-invalid-contact-type.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,foo,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,foo,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-invalid-date-format.csv b/tests/features/data/well-inventory-invalid-date-format.csv index 806573d9..c65d1d8d 100644 --- a/tests/features/data/well-inventory-invalid-date-format.csv +++ b/tests/features/data/well-inventory-invalid-date-format.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,25-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,25-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-invalid-date.csv b/tests/features/data/well-inventory-invalid-date.csv index 697f9c29..b5676025 100644 --- a/tests/features/data/well-inventory-invalid-date.csv +++ b/tests/features/data/well-inventory-invalid-date.csv @@ -1,5 +1,5 @@ well_name_point_id,site_name,date_time,field_staff,contact_role,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method WELL005,Site Alpha,2025-02-30T10:30:0,Jane Doe,Owner,250000,4000000,13N,5120.5,GPS -WELL006,Site Beta,2025-13-20T09:15:00,John Smith,Manager,250000,4000000,13N,5130.7,Survey -WELL007,Site Gamma,not-a-date,Emily Clark,Supervisor,250000,4000000,13N,5150.3,Survey +WELL006,Site Beta,2025-13-20T09:15:00,John Smith,Manager,250000,4000000,13N,5130.7,Survey-grade GPS +WELL007,Site Gamma,not-a-date,Emily Clark,Supervisor,250000,4000000,13N,5150.3,Survey-grade GPS WELL008,Site Delta,2025-04-10 11:00:00,Michael Lee,Technician,250000,4000000,13N,5160.4,GPS diff --git a/tests/features/data/well-inventory-invalid-email.csv b/tests/features/data/well-inventory-invalid-email.csv index 13374bc1..ff67551b 100644 --- a/tests/features/data/well-inventory-invalid-email.csv +++ b/tests/features/data/well-inventory-invalid-email.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smithexample.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smithexample.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"Reported by person other than driller owner agency",Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-invalid-lexicon.csv b/tests/features/data/well-inventory-invalid-lexicon.csv index f9f5dda4..9701bb8f 100644 --- a/tests/features/data/well-inventory-invalid-lexicon.csv +++ b/tests/features/data/well-inventory-invalid-lexicon.csv @@ -1,5 +1,5 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,contact_role,contact_type -ProjectA,WELL001,Site1,2025-02-15T10:30:00,John Doe,250000,4000000,13N,5000,Survey,2.5,INVALID_ROLE,owner -ProjectB,WELL002,Site2,2025-02-16T11:00:00,Jane Smith,250000,4000000,13N,5100,Survey,2.7,manager,INVALID_TYPE -ProjectC,WELL003,Site3,2025-02-17T09:45:00,Jim Beam,250000,4000000,13N,5200,INVALID_METHOD,2.6,manager,owner -ProjectD,WELL004,Site4,2025-02-18T08:20:00,Jack Daniels,250000,4000000,13N,5300,Survey,2.8,INVALID_ROLE,INVALID_TYPE +ProjectA,WELL001,Site1,2025-02-15T10:30:00,John Doe,250000,4000000,13N,5000,Survey-grade GPS,2.5,INVALID_ROLE,Primary +ProjectB,WELL002,Site2,2025-02-16T11:00:00,Jane Smith,250000,4000000,13N,5100,Survey-grade GPS,2.7,Manager,INVALID_TYPE +ProjectC,WELL003,Site3,2025-02-17T09:45:00,Jim Beam,250000,4000000,13N,5200,INVALID_METHOD,2.6,Manager,Primary +ProjectD,WELL004,Site4,2025-02-18T08:20:00,Jack Daniels,250000,4000000,13N,5300,Survey-grade GPS,2.8,INVALID_ROLE,INVALID_TYPE diff --git a/tests/features/data/well-inventory-invalid-numeric.csv b/tests/features/data/well-inventory-invalid-numeric.csv index 40675dc6..382ea6f5 100644 --- a/tests/features/data/well-inventory-invalid-numeric.csv +++ b/tests/features/data/well-inventory-invalid-numeric.csv @@ -1,6 +1,6 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft -ProjectA,WELL001,Site1,2025-02-15T10:30:00,John Doe,250000,4000000,13N,5000,Survey,2.5 -ProjectB,WELL002,Site2,2025-02-16T11:00:00,Jane Smith,250000,4000000,13N,5100,Survey,2.7 -ProjectC,WELL003,Site3,2025-02-17T09:45:00,Jim Beam,250000,4000000,13N,5200,Survey,2.6 -ProjectD,WELL004,Site4,2025-02-18T08:20:00,Jack Daniels,250000,4000000,13N,elev_bad,Survey,2.8 -ProjectE,WELL005,Site5,2025-02-19T12:00:00,Jill Hill,250000,4000000,13N,5300,Survey,not_a_height +ProjectA,WELL001,Site1,2025-02-15T10:30:00,John Doe,250000,4000000,13N,5000,Survey-grade GPS,2.5 +ProjectB,WELL002,Site2,2025-02-16T11:00:00,Jane Smith,250000,4000000,13N,5100,Survey-grade GPS,2.7 +ProjectC,WELL003,Site3,2025-02-17T09:45:00,Jim Beam,250000,4000000,13N,5200,Survey-grade GPS,2.6 +ProjectD,WELL004,Site4,2025-02-18T08:20:00,Jack Daniels,250000,4000000,13N,elev_bad,Survey-grade GPS,2.8 +ProjectE,WELL005,Site5,2025-02-19T12:00:00,Jill Hill,250000,4000000,13N,5300,Survey-grade GPS,not_a_height diff --git a/tests/features/data/well-inventory-invalid-partial.csv b/tests/features/data/well-inventory-invalid-partial.csv index 9535fd00..8dcdf3b8 100644 --- a/tests/features/data/well-inventory-invalid-partial.csv +++ b/tests/features/data/well-inventory-invalid-partial.csv @@ -1,4 +1,4 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP3,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith F,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia G,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP3,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis G,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False -Middle Rio Grande Groundwater Monitoring,,Old Orchard Well1,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis F,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False \ No newline at end of file +Middle Rio Grande Groundwater Monitoring,MRG-001_MP3,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith F,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia G,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP3,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis G,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,,Old Orchard Well1,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis F,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-invalid-phone-number.csv b/tests/features/data/well-inventory-invalid-phone-number.csv index 6e3386f8..2060a8fc 100644 --- a/tests/features/data/well-inventory-invalid-phone-number.csv +++ b/tests/features/data/well-inventory-invalid-phone-number.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,55-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,55-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"Reported by person other than driller owner agency",Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-invalid-postal-code.csv b/tests/features/data/well-inventory-invalid-postal-code.csv index 337c325d..24d30f59 100644 --- a/tests/features/data/well-inventory-invalid-postal-code.csv +++ b/tests/features/data/well-inventory-invalid-postal-code.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,8731,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Jemily Javis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,8731,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Jemily Javis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"Reported by person other than driller owner agency",Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-invalid-utm.csv b/tests/features/data/well-inventory-invalid-utm.csv index a1576354..e8f14b2b 100644 --- a/tests/features/data/well-inventory-invalid-utm.csv +++ b/tests/features/data/well-inventory-invalid-utm.csv @@ -1,3 +1,4 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,457100,4159020,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13S,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,457100,4159020,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13S,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"Reported by person other than driller owner agency",Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-005_MP1,Valid Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True diff --git a/tests/features/data/well-inventory-missing-address-type.csv b/tests/features/data/well-inventory-missing-address-type.csv index 28ecc032..d7b9846e 100644 --- a/tests/features/data/well-inventory-missing-address-type.csv +++ b/tests/features/data/well-inventory-missing-address-type.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-missing-contact-role.csv b/tests/features/data/well-inventory-missing-contact-role.csv index fc475194..a053650d 100644 --- a/tests/features/data/well-inventory-missing-contact-role.csv +++ b/tests/features/data/well-inventory-missing-contact-role.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,David Emily,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith No Role,NMBGMR,,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,David Emily,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"Reported by person other than driller owner agency",Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-missing-contact-type.csv b/tests/features/data/well-inventory-missing-contact-type.csv index b4ec4120..d3b41faa 100644 --- a/tests/features/data/well-inventory-missing-contact-type.csv +++ b/tests/features/data/well-inventory-missing-contact-type.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith No Type,NMBGMR,Owner,,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-missing-email-type.csv b/tests/features/data/well-inventory-missing-email-type.csv index 4e1f722c..2354c7e7 100644 --- a/tests/features/data/well-inventory-missing-email-type.csv +++ b/tests/features/data/well-inventory-missing-email-type.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-missing-phone-type.csv b/tests/features/data/well-inventory-missing-phone-type.csv index 739687f5..649ab568 100644 --- a/tests/features/data/well-inventory-missing-phone-type.csv +++ b/tests/features/data/well-inventory-missing-phone-type.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,OSE well record,280,45,owner estimate,submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,historic log scanned,350,60,historic log,vertical turbine inactive,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,From driller's log or well report,280,45,"Memory of owner, operator, driller",submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,"From driller's log or well report",Line Shaft,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/data/well-inventory-missing-required.csv b/tests/features/data/well-inventory-missing-required.csv index 9105a830..4d9fcdf0 100644 --- a/tests/features/data/well-inventory-missing-required.csv +++ b/tests/features/data/well-inventory-missing-required.csv @@ -1,5 +1,5 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft -ProjectA,,Site1,2025-02-15T10:30:00,John Doe,250000,4000000,13N,5000,Survey,2.5 -ProjectB,,Site2,2025-02-16T11:00:00,Jane Smith,250000,4000000,13N,5100,Survey,2.7 -ProjectC,WELL003,Site3,2025-02-17T09:45:00,Jim Beam,250000,4000000,13N,5200,Survey,2.6 -ProjectD,,Site4,2025-02-18T08:20:00,Jack Daniels,250000,4000000,13N,5300,Survey,2.8 +ProjectA,,Site1,2025-02-15T10:30:00,John Doe,250000,4000000,13N,5000,Survey-grade GPS,2.5 +ProjectB,,Site2,2025-02-16T11:00:00,Jane Smith,250000,4000000,13N,5100,Survey-grade GPS,2.7 +ProjectC,WELL003,Site3,2025-02-17T09:45:00,Jim Beam,250000,4000000,13N,5200,Survey-grade GPS,2.6 +ProjectD,,Site4,2025-02-18T08:20:00,Jack Daniels,250000,4000000,13N,5300,Survey-grade GPS,2.8 diff --git a/tests/features/data/well-inventory-missing-wl-fields.csv b/tests/features/data/well-inventory-missing-wl-fields.csv index cbfa8546..0908e36f 100644 --- a/tests/features/data/well-inventory-missing-wl-fields.csv +++ b/tests/features/data/well-inventory-missing-wl-fields.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible,depth_to_water_ft -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,active,Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True,100 -Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False,200 +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1,Smith Farm Domestic Well,2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True,100 +Middle Rio Grande Groundwater Monitoring,MRG-003_MP1,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False,200 diff --git a/tests/features/data/well-inventory-valid-comma-in-quotes.csv b/tests/features/data/well-inventory-valid-comma-in-quotes.csv index b66d673e..ab5509a8 100644 --- a/tests/features/data/well-inventory-valid-comma-in-quotes.csv +++ b/tests/features/data/well-inventory-valid-comma-in-quotes.csv @@ -1,3 +1,3 @@ project,well_name_point_id,site_name,date_time,field_staff,utm_easting,utm_northing,utm_zone,elevation_ft,elevation_method,measuring_point_height_ft,field_staff_2,field_staff_3,contact_1_name,contact_1_organization,contact_1_role,contact_1_type,contact_1_phone_1,contact_1_phone_1_type,contact_1_phone_2,contact_1_phone_2_type,contact_1_email_1,contact_1_email_1_type,contact_1_email_2,contact_1_email_2_type,contact_1_address_1_line_1,contact_1_address_1_line_2,contact_1_address_1_type,contact_1_address_1_state,contact_1_address_1_city,contact_1_address_1_postal_code,contact_1_address_2_line_1,contact_1_address_2_line_2,contact_1_address_2_type,contact_1_address_2_state,contact_1_address_2_city,contact_1_address_2_postal_code,contact_2_name,contact_2_organization,contact_2_role,contact_2_type,contact_2_phone_1,contact_2_phone_1_type,contact_2_phone_2,contact_2_phone_2_type,contact_2_email_1,contact_2_email_1_type,contact_2_email_2,contact_2_email_2_type,contact_2_address_1_line_1,contact_2_address_1_line_2,contact_2_address_1_type,contact_2_address_1_state,contact_2_address_1_city,contact_2_address_1_postal_code,contact_2_address_2_line_1,contact_2_address_2_line_2,contact_2_address_2_type,contact_2_address_2_state,contact_2_address_2_city,contact_2_address_2_postal_code,directions_to_site,specific_location_of_well,repeat_measurement_permission,sampling_permission,datalogger_installation_permission,public_availability_acknowledgement,result_communication_preference,contact_special_requests_notes,ose_well_record_id,date_drilled,completion_source,total_well_depth_ft,historic_depth_to_water_ft,depth_source,well_pump_type,well_pump_depth_ft,is_open,datalogger_possible,casing_diameter_ft,measuring_point_description,well_purpose,well_purpose_2,well_status,monitoring_frequency,sampling_scenario_notes,well_measuring_notes,sample_possible -Middle Rio Grande Groundwater Monitoring,MRG-001_MP1D,"""Smith Farm, Domestic Well""",2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith T,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia G,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True +Middle Rio Grande Groundwater Monitoring,MRG-001_MP1D,"Smith Farm, Domestic Well",2025-02-15T10:30:00,A Lopez,250000,4000000,13N,5250,Survey-grade GPS,1.5,B Chen,,John Smith T,NMBGMR,Owner,Primary,505-555-0101,Primary,,,john.smith@example.com,Primary,,,123 County Rd 7,,Mailing,NM,Los Lunas,87031,,,,,,,Maria Garcia G,NMBGMR,Principal Investigator,Secondary,505-555-0123,Home,,,maria.garcia@mrgcd.nm.gov,Work,,,1931 2nd St SW,Suite 200,Mailing,NM,Albuquerque,87102,,,,,,,Gate off County Rd 7 0.4 miles south of canal crossing,Domestic well in pump house east of residence,True,True,True,True,email,Call before visits during irrigation season,OSE-123456,2010-06-15,Interpreted fr geophys logs by source agency,280,45,"Memory of owner, operator, driller",Submersible,200,True,True,0.5,Top of steel casing inside pump house marked with orange paint,Domestic,,"Active, pumping well",Biannual,Sample only when pump has been off more than 12 hours,Measure before owner starts irrigation,True Middle Rio Grande Groundwater Monitoring,MRG-003_MP1G,Old Orchard Well,2025-01-20T09:00:00,B Chen,250000,4000000,13N,5320,Global positioning system (GPS),1.8,,,Emily Davis E,NMBGMR,Biologist,Primary,505-555-0303,Work,,,emily.davis@example.org,Work,,,78 Orchard Ln,,Mailing,NM,Los Lunas,87031,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,From Main St turn east on Orchard Ln well house at dead end,Abandoned irrigation well in small cinderblock building,False,False,False,True,phone,Owner prefers weekday visits,,1965-04-10,From driller's log or well report,350,60,From driller's log or well report,Jet,280,False,False,0.75,Top of steel casing under removable hatch use fixed reference mark,Irrigation,,Abandoned,Annual,Sampling not permitted water level only when owner present,Well house can be locked coordinate ahead,False diff --git a/tests/features/environment.py b/tests/features/environment.py index 4f3a6d2b..9cdff0d6 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -17,6 +17,11 @@ import random from datetime import datetime, timedelta +# Default BDD runs to the local test database before any db module imports. +# Allow explicit CI/local environment configuration to override these values. +os.environ.setdefault("POSTGRES_DB", "ocotilloapi_test") +os.environ.setdefault("POSTGRES_PORT", "5432") + from alembic import command from alembic.config import Config from sqlalchemy import select @@ -53,7 +58,7 @@ ) from db.engine import session_ctx from db.initialization import recreate_public_schema, sync_search_vector_triggers -from services.util import get_bool_env +from services.env import get_bool_env def add_context_object_container(name): diff --git a/tests/features/steps/cli_common.py b/tests/features/steps/cli_common.py index 1483db09..03b8077a 100644 --- a/tests/features/steps/cli_common.py +++ b/tests/features/steps/cli_common.py @@ -62,7 +62,7 @@ def step_impl_command_exit_zero(context): @then("the command exits with a non-zero exit code") def step_impl_command_exit_nonzero(context): - assert context.cli_result.exit_code != 0 + assert context.cli_result.exit_code != 0, context.cli_result.exit_code # ============= EOF ============================================= diff --git a/tests/features/steps/water-levels-csv.py b/tests/features/steps/water-levels-csv.py index 4a8d6b57..05257163 100644 --- a/tests/features/steps/water-levels-csv.py +++ b/tests/features/steps/water-levels-csv.py @@ -23,27 +23,50 @@ from db import Observation from db.engine import session_ctx from services.water_level_csv import bulk_upload_water_levels +from tests.features.environment import ( + add_location, + add_measuring_point_history, + add_well, +) REQUIRED_FIELDS: List[str] = [ "field_staff", "well_name_point_id", "field_event_date_time", - "measurement_date_time", - "sampler", + "water_level_date_time", + "measuring_person", "sample_method", +] +OPTIONAL_FIELDS = [ + "field_staff_2", + "field_staff_3", "mp_height", "level_status", "depth_to_water_ft", "data_quality", + "water_level_notes", +] +VALID_SAMPLE_METHODS = [ + "Electric tape measurement (E-probe)", + "Steel-tape measurement", +] +VALID_LEVEL_STATUSES = ["Water level not affected", "Site was dry"] +VALID_DATA_QUALITIES = [ + "Water level accurate to within two hundreths of a foot", + "None", ] -OPTIONAL_FIELDS = ["water_level_notes"] -VALID_SAMPLERS = ["Groundwater Team", "Consultant"] -VALID_SAMPLE_METHODS = ["electric tape", "steel tape"] -VALID_LEVEL_STATUSES = ["stable", "rising", "falling"] -VALID_DATA_QUALITIES = ["approved", "provisional"] def _available_well_names(context: Context) -> list[str]: + if "wells" not in context.objects or not context.objects["wells"]: + with session_ctx() as session: + loc_1 = add_location(context, session) + loc_2 = add_location(context, session) + well_1 = add_well(context, session, loc_1, name_num=101) + well_2 = add_well(context, session, loc_2, name_num=102) + add_measuring_point_history(context, session, well_1) + add_measuring_point_history(context, session, well_2) + if not hasattr(context, "well_names"): context.well_names = [well.name for well in context.objects["wells"]] return context.well_names @@ -53,16 +76,19 @@ def _base_row(context: Context, index: int) -> Dict[str, str]: well_names = _available_well_names(context) well_name = well_names[(index - 1) % len(well_names)] measurement_day = 14 + index + field_staff = "A Lopez" if index == 1 else "B Chen" return { - "field_staff": "A Lopez" if index == 1 else "B Chen", + "field_staff": field_staff, + "field_staff_2": "", + "field_staff_3": "", "well_name_point_id": well_name, - "field_event_date_time": f"2025-02-{measurement_day:02d}T08:00:00-07:00", - "measurement_date_time": f"2025-02-{measurement_day:02d}T10:30:00-07:00", - "sampler": VALID_SAMPLERS[(index - 1) % len(VALID_SAMPLERS)], + "field_event_date_time": f"2025-02-{measurement_day:02d}T08:00:00", + "water_level_date_time": f"2025-02-{measurement_day:02d}T10:30:00", + "measuring_person": field_staff, "sample_method": VALID_SAMPLE_METHODS[(index - 1) % len(VALID_SAMPLE_METHODS)], "mp_height": "1.5" if index == 1 else "1.8", "level_status": VALID_LEVEL_STATUSES[(index - 1) % len(VALID_LEVEL_STATUSES)], - "depth_to_water_ft": "45.2" if index == 1 else "47.0", + "depth_to_water_ft": "7.0" if index == 1 else "", "data_quality": VALID_DATA_QUALITIES[(index - 1) % len(VALID_DATA_QUALITIES)], "water_level_notes": "Initial measurement" if index == 1 else "Follow-up", } @@ -89,6 +115,7 @@ def _write_csv_to_context(context: Context) -> None: temp_file.close() context.csv_file = str(Path(temp_file.name)) context.csv_raw_text = csv_text + context.file_content = csv_text def _set_rows( @@ -146,21 +173,53 @@ def step_given_each_well_name_point_id_value_matches_an_existing_well(context: C @given( - '"measurement_date_time" values are valid ISO 8601 timestamps with timezone offsets (e.g. "2025-02-15T10:30:00-08:00")' + '"field_event_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T08:00:00")' ) -def step_step_step(context: Context): +def step_given_field_event_date_time_values_are_valid_naive_iso_datetimes( + context: Context, +): for row in context.csv_rows: - assert row["measurement_date_time"].startswith("2025-02") - assert "T" in row["measurement_date_time"] + assert row["field_event_date_time"].startswith("2025-02") + assert "T" in row["field_event_date_time"] + assert "+" not in row["field_event_date_time"] + assert row["field_event_date_time"].count(":") == 2 -# @given("the water level CSV includes optional fields when available:") -# def step_impl(context: Context): -# field_name = context.table.headings[0] -# optional_fields = [row[field_name].strip() for row in context.table] -# headers = set(context.csv_headers) -# missing = [field for field in optional_fields if field not in headers] -# assert not missing, f"Missing optional headers: {missing}" +@given( + '"water_level_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00")' +) +def step_given_water_level_date_time_values_are_valid_naive_iso_datetimes( + context: Context, +): + for row in context.csv_rows: + assert row["water_level_date_time"].startswith("2025-02") + assert "T" in row["water_level_date_time"] + assert "+" not in row["water_level_date_time"] + assert row["water_level_date_time"].count(":") == 2 + + +@given( + 'when provided, "sample_method", "level_status", and "data_quality" values are valid lexicon values' +) +def step_given_lexicon_values_are_valid(context: Context): + for row in context.csv_rows: + if row.get("sample_method"): + assert row["sample_method"] in VALID_SAMPLE_METHODS + if row.get("level_status"): + assert row["level_status"] in VALID_LEVEL_STATUSES + if row.get("data_quality"): + assert row["data_quality"] in VALID_DATA_QUALITIES + + +@given("the water level CSV includes optional fields when available:") +def step_given_the_water_level_csv_includes_optional_fields_when_available( + context: Context, +): + field_name = context.table.headings[0] + optional_fields = [row[field_name].strip() for row in context.table] + headers = set(context.csv_headers) + missing = [field for field in optional_fields if field not in headers] + assert not missing, f"Missing optional headers: {missing}" @when("I run the CLI command:") @@ -217,10 +276,26 @@ def step_then_stderr_should_be_empty(context: Context): # ============================================================================ # Scenario: Upload succeeds when required columns are present but reordered # ============================================================================ +@given( + "my water level CSV file uses legacy alias headers for measurement date, sampler, and measuring point height" +) +def step_given_my_water_level_csv_file_uses_legacy_alias_headers(context: Context): + rows = _build_valid_rows(context) + alias_rows = [] + for row in rows: + alias_row = dict(row) + alias_row["measurement_date_time"] = alias_row.pop("water_level_date_time") + alias_row["sampler"] = alias_row.pop("measuring_person") + alias_row["mp_height_ft"] = alias_row.pop("mp_height") + alias_rows.append(alias_row) + headers = list(alias_rows[0].keys()) + _set_rows(context, alias_rows, headers=headers) + + @given( "my water level CSV file contains all required headers but in a different column order" ) -def step_step_step_2(context: Context): +def step_given_my_water_level_csv_file_contains_reordered_headers(context: Context): rows = _build_valid_rows(context) headers = list(reversed(list(rows[0].keys()))) _set_rows(context, rows, headers=headers) @@ -299,13 +374,13 @@ def step_then_stderr_should_contain_a_validation_error_for_the_required_field( # Scenario: Upload fails due to invalid date formats # ============================================================================ @given( - 'my CSV file contains invalid ISO 8601 date values in the "measurement_date_time" field' + 'my CSV file contains invalid ISO 8601 date values in the "water_level_date_time" field' ) def step_step_step_6(context: Context): rows = _build_valid_rows(context, count=1) - rows[0]["measurement_date_time"] = "02/15/2025 10:30" + rows[0]["water_level_date_time"] = "02/15/2025 10:30" _set_rows(context, rows) - context.invalid_fields = ["measurement_date_time"] + context.invalid_fields = ["water_level_date_time"] @then("stderr should contain validation errors identifying the invalid field and row") @@ -323,7 +398,7 @@ def step_then_stderr_should_contain_validation_errors_identifying_the_invalid_fi # Scenario: Upload fails due to invalid numeric fields # ============================================================================ @given( - 'my CSV file contains values that cannot be parsed as numeric in numeric-required fields such as "mp_height" or "depth_to_water_ft"' + 'my CSV file contains values that cannot be parsed as numeric in numeric fields such as "mp_height" or "depth_to_water_ft"' ) def step_step_step_7(context: Context): rows = _build_valid_rows(context, count=1) @@ -337,21 +412,31 @@ def step_step_step_7(context: Context): # Scenario: Upload fails due to invalid lexicon values # ============================================================================ @given( - 'my CSV file contains invalid lexicon values for "sampler", "sample_method", "level_status", or "data_quality"' + 'my CSV file contains invalid lexicon values for "sample_method", "level_status", or "data_quality"' ) def step_step_step_8(context: Context): rows = _build_valid_rows(context, count=1) - rows[0]["sampler"] = "Unknown Team" rows[0]["sample_method"] = "mystery" rows[0]["level_status"] = "supercharged" rows[0]["data_quality"] = "bad" _set_rows(context, rows) context.invalid_fields = [ - "sampler", "sample_method", "level_status", "data_quality", ] +@given( + "my water level CSV file contains a row where measuring_person is not one of the supplied field staff" +) +def step_given_measuring_person_is_not_one_of_the_supplied_field_staff( + context: Context, +): + rows = _build_valid_rows(context, count=1) + rows[0]["measuring_person"] = "Unexpected Person" + _set_rows(context, rows) + context.invalid_fields = ["measuring_person"] + + # ============= EOF ============================================= diff --git a/tests/features/steps/well-inventory-csv-given.py b/tests/features/steps/well-inventory-csv-given.py index f02144fc..4f6b6278 100644 --- a/tests/features/steps/well-inventory-csv-given.py +++ b/tests/features/steps/well-inventory-csv-given.py @@ -29,17 +29,57 @@ def _set_file_content(context: Context, name): def _set_file_content_from_path(context: Context, path: Path, name: str | None = None): context.file_path = path - with open(path, "r", encoding="utf-8", newline="") as f: - context.file_name = name or path.name - context.file_content = f.read() - if context.file_name.endswith(".csv"): - context.rows = list(csv.DictReader(context.file_content.splitlines())) - context.row_count = len(context.rows) - context.file_type = "text/csv" + import hashlib + + context.file_name = name or path.name + + if path.suffix == ".csv" and path.exists() and path.stat().st_size > 0: + suffix = hashlib.md5(context.scenario.name.encode()).hexdigest()[:6] + with open(path, "r", encoding="utf-8", newline="") as f: + rows = list(csv.reader(f)) + + if rows: + header = rows[0] + well_id_indexes = [ + idx + for idx, column_name in enumerate(header) + if column_name == "well_name_point_id" + ] + for row in rows[1:]: + # Preserve repeated header rows and duplicate-column fixtures so + # structural CSV scenarios still reach the importer unchanged. + if row == header: + continue + + for idx in well_id_indexes: + if idx >= len(row): + continue + value = row[idx] + if ( + value + and str(value).strip() != "" + and not str(value).lower().endswith("-xxxx") + ): + row[idx] = f"{value}_{suffix}" + + buffer = StringIO() + csv.writer(buffer).writerows(rows) + context.file_content = buffer.getvalue() + context.rows = list(csv.DictReader(context.file_content.splitlines())) + context.row_count = len(context.rows) + context.file_type = "text/csv" + else: + # For empty files or non-CSV files, don't use pandas + if path.exists(): + with open(path, "r", encoding="utf-8", newline="") as f: + context.file_content = f.read() else: - context.rows = [] - context.row_count = 0 - context.file_type = "text/plain" + context.file_content = "" + context.rows = [] + context.row_count = 0 + context.file_type = ( + "text/csv" if context.file_name.endswith(".csv") else "text/plain" + ) @given( @@ -50,7 +90,7 @@ def step_step_step(context: Context): @given( - "my CSV file contains a row that has an invalid postal code format in contact_1_address_1_postal_code" + "my CSV file contains a row that has an invalid postal code format in contact_1_address_1_postal_code" ) def step_step_step_2(context: Context): _set_file_content(context, "well-inventory-invalid-postal-code.csv") @@ -243,7 +283,11 @@ def step_given_my_csv_file_contains_a_row_missing_the_required_required( ): _set_file_content(context, "well-inventory-valid.csv") - df = pd.read_csv(context.file_path, dtype={"contact_2_address_1_postal_code": str}) + df = pd.read_csv( + context.file_path, + dtype={"contact_2_address_1_postal_code": str}, + keep_default_na=False, + ) df = df.drop(required_field, axis=1) buffer = StringIO() @@ -274,7 +318,26 @@ def step_step_step_16(context: Context): def _get_valid_df(context: Context) -> pd.DataFrame: _set_file_content(context, "well-inventory-valid.csv") - df = pd.read_csv(context.file_path, dtype={"contact_2_address_1_postal_code": str}) + df = pd.read_csv( + context.file_path, + dtype={"contact_2_address_1_postal_code": str}, + keep_default_na=False, + ) + + # Add unique suffix to well names to ensure isolation between scenarios + # using a simple hash of the scenario name + import hashlib + + suffix = hashlib.md5(context.scenario.name.encode()).hexdigest()[:6] + if "well_name_point_id" in df.columns: + df["well_name_point_id"] = df["well_name_point_id"].apply( + lambda x: ( + f"{x}_{suffix}" + if x and str(x).strip() != "" and not str(x).lower().endswith("-xxxx") + else x + ) + ) + return df @@ -362,4 +425,89 @@ def step_step_step_21(context): _set_file_content(context, "well-inventory-missing-wl-fields.csv") +@given( + "my CSV file contains a row with an address_type value that is not one of: Work, Personal, Mailing, Physical" +) +def step_given_row_contains_invalid_address_type_value(context: Context): + df = _get_valid_df(context) + df.loc[0, "contact_1_address_1_type"] = "InvalidAddressType" + _set_content_from_df(context, df) + + +@given( + "my CSV file contains a row with a state value that is not a valid 2-letter US state abbreviation" +) +def step_given_row_contains_invalid_state_value(context: Context): + df = _get_valid_df(context) + df.loc[0, "contact_1_address_1_state"] = "New Mexico" + _set_content_from_df(context, df) + + +@given( + 'my CSV file contains a row with a well_hole_status value that is not one of: "Abandoned", "Active, pumping well", "Destroyed, exists but not usable", "Inactive, exists but not used"' +) +def step_given_row_contains_invalid_well_hole_status_value(context: Context): + df = _get_valid_df(context) + if "well_hole_status" in df.columns: + df.loc[0, "well_hole_status"] = "NotARealWellHoleStatus" + elif "well_status" in df.columns: + df.loc[0, "well_status"] = "NotARealWellHoleStatus" + _set_content_from_df(context, df) + + +@given( + 'my CSV file contains a row with a monitoring_status value that is not one of: "Open", "Open (unequipped)", "Closed", "Datalogger can be installed", "Datalogger cannot be installed", "Abandoned", "Active, pumping well", "Destroyed, exists but not usable", "Inactive, exists but not used", "Currently monitored", "Not currently monitored"' +) +def step_given_row_contains_invalid_monitoring_status_value(context: Context): + df = _get_valid_df(context) + if "monitoring_frequency" in df.columns: + df.loc[0, "monitoring_frequency"] = "NotARealMonitoringStatus" + _set_content_from_df(context, df) + + +@given('my CSV file contains a row with monitoring_frequency set to "Complete"') +def step_given_row_contains_complete_monitoring_frequency(context: Context): + df = _get_valid_df(context) + df.loc[0, "monitoring_frequency"] = "Complete" + context.complete_monitoring_frequency_well_id = df.loc[0, "well_name_point_id"] + _set_content_from_df(context, df) + + +@given( + 'my CSV file contains a row with a well_pump_type value that is not one of: "Submersible", "Jet", "Line Shaft", "Hand"' +) +def step_given_row_contains_invalid_well_pump_type_value(context: Context): + df = _get_valid_df(context) + df.loc[0, "well_pump_type"] = "NotARealPumpType" + _set_content_from_df(context, df) + + +@given( + 'my CSV file contains a row with contact fields filled but both "contact_1_name" and "contact_1_organization" are blank' +) +def step_given_row_contains_contact_fields_but_name_and_org_are_blank(context: Context): + df = _get_valid_df(context) + # Keep row 2 unchanged so row 1's invalid contact is the only expected error. + df.loc[0, "contact_1_name"] = "" + df.loc[0, "contact_1_organization"] = "" + + # Keep other contact data present so composite contact validation is exercised. + df.loc[0, "contact_1_role"] = "Owner" + df.loc[0, "contact_1_type"] = "Primary" + + _set_content_from_df(context, df) + + +@given( + 'my CSV file contains a row where "depth_to_water_ft" is filled but "water_level_date_time" is blank' +) +@given( + 'my csv file contains a row where "depth_to_water_ft" is filled but "water_level_date_time" is blank' +) +def step_given_depth_to_water_is_filled_but_water_level_date_time_is_blank( + context: Context, +): + _set_file_content(context, "well-inventory-missing-wl-fields.csv") + + # ============= EOF ============================================= diff --git a/tests/features/steps/well-inventory-csv-validation-error.py b/tests/features/steps/well-inventory-csv-validation-error.py index 8aecbeae..0c390009 100644 --- a/tests/features/steps/well-inventory-csv-validation-error.py +++ b/tests/features/steps/well-inventory-csv-validation-error.py @@ -21,14 +21,48 @@ def _handle_validation_error(context, expected_errors): response_json = context.response.json() validation_errors = response_json.get("validation_errors", []) - assert len(validation_errors) == len( - expected_errors - ), f"Expected {len(expected_errors)} validation errors, got {len(validation_errors)}" - for v, e in zip(validation_errors, expected_errors): - assert v["field"] == e["field"], f"Expected {e['field']} for {v['field']}" - assert v["error"] == e["error"], f"Expected {e['error']} for {v['error']}" - if "value" in e: - assert v["value"] == e["value"], f"Expected {e['value']} for {v['value']}" + + def _matches(expected, actual): + field_match = str(expected.get("field", "")) in str(actual.get("field", "")) + error_match = str(expected.get("error", "")) in str(actual.get("error", "")) + return field_match and error_match + + def _find_match(expected_idx: int, used_indices: set[int]) -> bool: + if expected_idx == len(expected_errors): + return True + + expected = expected_errors[expected_idx] + for actual_idx, actual in enumerate(validation_errors): + if actual_idx in used_indices or not _matches(expected, actual): + continue + if _find_match(expected_idx + 1, used_indices | {actual_idx}): + return True + return False + + assert _find_match(0, set()), ( + f"Expected at least {len(expected_errors)} distinct validation error matches for " + f"{expected_errors}. Got: {validation_errors}" + ) + + +def _assert_any_validation_error_contains( + context: Context, field_fragment: str | None, error_fragment: str +): + response_json = context.response.json() + validation_errors = response_json.get("validation_errors", []) + assert validation_errors, "Expected at least one validation error" + found = False + for error in validation_errors: + field = str(error.get("field", "")) + message = str(error.get("error", "")) + if field_fragment and field_fragment not in field: + continue + if error_fragment in message: + found = True + break + assert ( + found + ), f"Expected validation error containing field '{field_fragment}' and message '{error_fragment}'" @then( @@ -107,7 +141,7 @@ def step_step_step_5(context): expected_errors = [ { "field": "composite field error", - "error": "Value error, contact_1_role must be provided if name is provided", + "error": "Value error, contact_1_role is required when contact fields are provided", } ] _handle_validation_error(context, expected_errors) @@ -153,13 +187,26 @@ def step_then_the_response_includes_a_validation_error_indicating_the_invalid_em @then( - 'the response includes a validation error indicating the missing "contact_type" value' + 'the response includes a validation error indicating the missing "contact_role" value' ) def step_step_step_8(context): expected_errors = [ { "field": "composite field error", - "error": "Value error, contact_1_type must be provided if name is provided", + "error": "Value error, contact_1_role is required when contact data is provided", + } + ] + _handle_validation_error(context, expected_errors) + + +@then( + 'the response includes a validation error indicating the missing "contact_type" value' +) +def step_step_step_9(context): + expected_errors = [ + { + "field": "composite field error", + "error": "Value error, contact_1_type is required when contact data is provided", } ] _handle_validation_error(context, expected_errors) @@ -214,4 +261,84 @@ def step_step_step_10(context): _handle_validation_error(context, expected_errors) +@then( + 'the response includes a validation error indicating an invalid "address_type" value' +) +def step_then_response_includes_invalid_address_type_error(context: Context): + _assert_any_validation_error_contains(context, "address", "Input should be") + + +@then("the response includes a validation error indicating an invalid state value") +def step_then_response_includes_invalid_state_error(context: Context): + _assert_any_validation_error_contains( + context, "state", "Value error, State must be a 2 letter abbreviation" + ) + + +@then( + 'the response includes a validation error indicating an invalid "well_hole_status" value' +) +def step_then_response_includes_invalid_well_hole_status_error(context: Context): + response_json = context.response.json() + validation_errors = response_json.get("validation_errors", []) + assert validation_errors, "Expected at least one validation error" + found = any( + str(error.get("field", "")) in {"well_hole_status", "well_status"} + and "Input should be" in str(error.get("error", "")) + for error in validation_errors + ) + assert ( + found + ), f"Expected well_hole_status/well_status validation error. Got: {validation_errors}" + + +@then( + 'the response includes a validation error indicating an invalid "monitoring_status" value' +) +def step_then_response_includes_invalid_monitoring_status_error(context: Context): + _assert_any_validation_error_contains(context, "monitoring", "Input should be") + + +@then( + 'the response includes a validation error indicating an invalid "well_pump_type" value' +) +def step_then_response_includes_invalid_well_pump_type_error(context: Context): + _assert_any_validation_error_contains(context, "well_pump_type", "Input should be") + + +@then( + 'the response includes a validation error indicating that at least one of "contact_1_name" or "contact_1_organization" must be provided' +) +@then( + 'the response includes validation errors indicating that both "contact_1_name" and "contact_1_organization" must be provided when any contact information is present' +) +def step_then_response_includes_contact_name_or_org_required_error(context: Context): + response_json = context.response.json() + validation_errors = response_json.get("validation_errors", []) + assert validation_errors, f"Expected validation errors, got: {response_json}" + found = any( + "composite field error" in str(err.get("field", "")) + and ( + "At least one of contact_1_name or contact_1_organization must be provided" + in str(err.get("error", "")) + ) + for err in validation_errors + ) + + assert ( + found + ), f"Expected contact validation error requiring contact_1_name or contact_1_organization. Got: {validation_errors}" + + +@then( + 'the response includes a validation error indicating that "water_level_date_time" is required when "depth_to_water_ft" is provided' +) +def step_then_response_includes_water_level_datetime_required_error(context: Context): + _assert_any_validation_error_contains( + context, + "composite field error", + "water_level_date_time is required when depth_to_water_ft is provided", + ) + + # ============= EOF ============================================= diff --git a/tests/features/steps/well-inventory-csv.py b/tests/features/steps/well-inventory-csv.py index 8b23b0be..bba4b679 100644 --- a/tests/features/steps/well-inventory-csv.py +++ b/tests/features/steps/well-inventory-csv.py @@ -6,6 +6,7 @@ from behave import given, when, then from behave.runner import Context from cli.service_adapter import well_inventory_csv +from db import Thing from db.engine import session_ctx from db.lexicon import LexiconCategory from services.util import convert_dt_tz_naive_to_tz_aware @@ -244,11 +245,20 @@ def step_then_the_response_identifies_the_row_and_field_for_each_error( assert "field" in error, "Expected validation error to include field name" -@then("no wells are imported") -def step_then_no_wells_are_imported(context: Context): +@then("{count:d} wells are imported") +@then("{count:d} well is imported") +def step_then_count_wells_are_imported(context: Context, count: int): response_json = context.response.json() wells = response_json.get("wells", []) - assert len(wells) == 0, "Expected no wells to be imported" + validation_errors = response_json.get("validation_errors", []) + assert ( + len(wells) == count + ), f"Expected {count} wells to be imported, but got {len(wells)}: {wells}. Errors: {validation_errors}" + + +@then("no wells are imported") +def step_then_no_wells_are_imported(context: Context): + step_then_count_wells_are_imported(context, 0) @then("the response includes validation errors indicating duplicated values") @@ -364,8 +374,10 @@ def step_then_the_response_includes_a_validation_error_for_the_required_field( response_json = context.response.json() assert "validation_errors" in response_json, "Expected validation errors" vs = response_json["validation_errors"] - assert len(vs) == 2, "Expected 2 validation error" - assert vs[0]["field"] == required_field + assert len(vs) >= 1, "Expected at least 1 validation error" + assert any( + v["field"] == required_field for v in vs + ), f"Expected validation error for {required_field}, but got {vs}" @then("the response includes an error message indicating the row limit was exceeded") @@ -404,3 +416,20 @@ def step_then_all_wells_are_imported_with_system_generated_unique_well_name( assert len(well_ids) == len( set(well_ids) ), "Expected unique well_name_point_id values" + + +@then( + 'the imported well with monitoring_frequency "Complete" is marked not currently monitored' +) +def step_then_complete_monitoring_frequency_maps_to_not_currently_monitored( + context: Context, +): + with session_ctx() as session: + thing = session.scalars( + select(Thing).where( + Thing.name == context.complete_monitoring_frequency_well_id + ) + ).one() + + assert thing.monitoring_status == "Not currently monitored" + assert thing.monitoring_frequencies == [] diff --git a/tests/features/water-level-csv.feature b/tests/features/water-level-csv.feature index d924da6f..1c3b7511 100644 --- a/tests/features/water-level-csv.feature +++ b/tests/features/water-level-csv.feature @@ -1,21 +1,10 @@ -# features/cli/bulk_upload_water_levels.feature - @cli @backend @BDMS-TBD Feature: Bulk upload water level entries from CSV via CLI As a hydrogeologist or data specialist I want to upload a CSV file containing water level entry data for multiple wells using a CLI command - So that water level records can be created efficiently and accurately in the system - -# Background: -# Given the CLI binary "bdms" is installed and available on the PATH -# And I have a valid CLI configuration for the target environment -# And valid lexicon values exist for: -# | lexicon category | -# | sample_method | -# | level_status | -# | data_quality | + So that groundwater-level records can be created efficiently and accurately in the system @positive @happy_path @BDMS-TBD @cleanup_samples Scenario: Uploading a valid water level entry CSV containing required and optional fields @@ -30,16 +19,18 @@ Feature: Bulk upload water level entries from CSV via CLI | water_level_date_time | | measuring_person | | sample_method | - | mp_height | - | level_status | - | depth_to_water_ft | - | data_quality | And each "well_name_point_id" value matches an existing well + And "field_event_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T08:00:00") And "water_level_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00") - And the CSV includes optional fields when available: + And when provided, "sample_method", "level_status", and "data_quality" values are valid lexicon values + And the water level CSV includes optional fields when available: | optional field name | | field_staff_2 | | field_staff_3 | + | mp_height | + | level_status | + | depth_to_water_ft | + | data_quality | | water_level_notes | When I run the CLI command: """ @@ -55,61 +46,52 @@ Feature: Bulk upload water level entries from CSV via CLI And stdout includes an array of created water level entry objects And stderr should be empty - @positive @validation @column_order @BDMS-TBD @cleanup_samples - Scenario: Upload succeeds when required columns are present but in a different order - Given my water level CSV file contains all required headers but in a different column order - And the CSV includes required fields: - | required field name | - | well_name_point_id | - | water_level_date_time | - | measuring_person | - | sample_method | - | mp_height | - | level_status | - | depth_to_water_ft | - | data_quality | + @positive @validation @aliases @BDMS-TBD @cleanup_samples + Scenario: Upload succeeds when legacy alias headers are used + Given my water level CSV file uses legacy alias headers for measurement date, sampler, and measuring point height When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json """ - # assumes users are entering datetimes as Mountain Time becuase well location is restricted to New Mexico - Then all datetime objects are assigned the correct Mountain Time timezone offset based on the date value. - And the command exits with code 0 + Then the command exits with code 0 + And stdout should be valid JSON And all water level entries are imported And stderr should be empty @positive @validation @extra_columns @BDMS-TBD @cleanup_samples - Scenario: Upload succeeds when CSV contains extra, unknown columns + Scenario: Upload succeeds when CSV contains extra columns Given my water level CSV file contains extra columns but is otherwise valid When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json """ Then the command exits with code 0 + And stdout should be valid JSON And all water level entries are imported And stderr should be empty - ########################################################################### - # NEGATIVE VALIDATION SCENARIOS - ########################################################################### - - @negative @validation @BDMS-TBD - Scenario: No water level entries are imported when any row fails validation + @positive @validation @partial_success @BDMS-TBD @cleanup_samples + Scenario: Valid rows are imported when another row fails validation Given my water level CSV contains 3 rows with 2 valid rows and 1 row missing the required "well_name_point_id" When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json """ - Then the command exits with a non-zero exit code + Then the command exits with code 0 + And stdout should be valid JSON + And stdout includes a summary containing: + | summary_field | value | + | total_rows_processed | 3 | + | total_rows_imported | 2 | + | validation_errors_or_warnings | 1 | And stderr should contain a validation error for the row missing "well_name_point_id" - And no water level entries are imported @negative @validation @required_fields @BDMS-TBD Scenario Outline: Upload fails when a required field is missing Given my water level CSV file contains a row missing the required "" field When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json """ Then the command exits with a non-zero exit code And stderr should contain a validation error for the "" field @@ -117,21 +99,19 @@ Feature: Bulk upload water level entries from CSV via CLI Examples: | required_field | + | field_staff | | well_name_point_id | + | field_event_date_time | | water_level_date_time | | measuring_person | | sample_method | - | mp_height | - | level_status | - | depth_to_water_ft | - | data_quality | @negative @validation @date_formats @BDMS-TBD Scenario: Upload fails due to invalid date formats Given my CSV file contains invalid ISO 8601 date values in the "water_level_date_time" field When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json """ Then the command exits with a non-zero exit code And stderr should contain validation errors identifying the invalid field and row @@ -139,21 +119,32 @@ Feature: Bulk upload water level entries from CSV via CLI @negative @validation @numeric_fields @BDMS-TBD Scenario: Upload fails due to invalid numeric fields - Given my CSV file contains values that cannot be parsed as numeric in numeric-required fields such as "mp_height" or "depth_to_water_ft" + Given my CSV file contains values that cannot be parsed as numeric in numeric fields such as "mp_height" or "depth_to_water_ft" When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json """ Then the command exits with a non-zero exit code And stderr should contain validation errors identifying the invalid field and row And no water level entries are imported @negative @validation @lexicon_values @BDMS-TBD - Scenario: Upload fails due to invalid lexicon values - Given my CSV file contains invalid lexicon values for "measuring_person", "sample_method", "level_status", or "data_quality" + Scenario: Upload fails due to invalid lexicon values for water level descriptor fields + Given my CSV file contains invalid lexicon values for "sample_method", "level_status", or "data_quality" When I run the CLI command: """ - oco water-levels bulk-upload --file ./water_levels.csv + oco water-levels bulk-upload --file ./water_levels.csv --output json + """ + Then the command exits with a non-zero exit code + And stderr should contain validation errors identifying the invalid field and row + And no water level entries are imported + + @negative @validation @measuring_person @BDMS-TBD + Scenario: Upload fails when measuring_person does not match supplied field staff + Given my water level CSV file contains a row where measuring_person is not one of the supplied field staff + When I run the CLI command: + """ + oco water-levels bulk-upload --file ./water_levels.csv --output json """ Then the command exits with a non-zero exit code And stderr should contain validation errors identifying the invalid field and row diff --git a/tests/features/well-inventory-csv.feature b/tests/features/well-inventory-csv.feature index e2d4e80e..0ee85bba 100644 --- a/tests/features/well-inventory-csv.feature +++ b/tests/features/well-inventory-csv.feature @@ -1,7 +1,7 @@ +@production @backend @cli @BDMS-TBD -@production Feature: Bulk upload well inventory from CSV via CLI As a hydrogeologist or data specialist I want to upload a CSV file containing well inventory data for multiple wells @@ -35,18 +35,15 @@ Feature: Bulk upload well inventory from CSV via CLI | required field name | | project | | well_name_point_id | - | site_name | | date_time | | field_staff | | utm_easting | | utm_northing | | utm_zone | - | elevation_ft | - | elevation_method | - | measuring_point_height_ft | And each "well_name_point_id" value is unique per row And the CSV includes optional fields when available: | optional field name | + | site_name | | field_staff_2 | | field_staff_3 | | contact_1_name | @@ -110,21 +107,30 @@ Feature: Bulk upload well inventory from CSV via CLI | completion_source | | total_well_depth_ft | | historic_depth_to_water_ft | + | historical_notes | | depth_source | | well_pump_type | | well_pump_depth_ft | | is_open | | datalogger_possible | | casing_diameter_ft | + | elevation_ft | + | elevation_method | + | measuring_point_height_ft | | measuring_point_description | | well_purpose | | well_purpose_2 | - | well_status | + | well_hole_status | + | well_status | | monitoring_frequency | + | monitoring_status | | sampling_scenario_notes | + | well_notes | + | well_measuring_notes | + | water_notes | | well_measuring_notes | | sample_possible | - And the csv includes optional water level entry fields when available: + And the csv includes optional water level entry fields when available: | water_level_entry fields | | measuring_person | | sample_method | @@ -132,10 +138,10 @@ Feature: Bulk upload well inventory from CSV via CLI | mp_height | | level_status | | depth_to_water_ft | - | data_quality | + | data_quality | | water_level_notes | - And the required "date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00") - And the optional "water_level_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00") when provided + And the required "date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00") + And the optional "water_level_date_time" values are valid ISO 8601 timezone-naive datetime strings (e.g. "2025-02-15T10:30:00") when provided # And all optional lexicon fields contain valid lexicon values when provided # And all optional numeric fields contain valid numeric values when provided @@ -145,7 +151,6 @@ Feature: Bulk upload well inventory from CSV via CLI # assumes users are entering datetimes as Mountain Time because location is restricted to New Mexico Then all datetime objects are assigned the correct Mountain Time timezone offset based on the date value. And the command exits with code 0 - And the system should return a response in JSON format # And null values in the response are represented as JSON null And the response includes a summary containing: | summary_field | value | @@ -161,18 +166,13 @@ Feature: Bulk upload well inventory from CSV via CLI | required field name | | project | | well_name_point_id | - | site_name | | date_time | | field_staff | | utm_easting | | utm_northing | | utm_zone | - | elevation_ft | - | elevation_method | - | measuring_point_height_ft | When I run the well inventory bulk upload command Then the command exits with code 0 - And the system should return a response in JSON format And all wells are imported @positive @validation @extra_columns @BDMS-TBD @@ -180,15 +180,21 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains extra columns but is otherwise valid When I run the well inventory bulk upload command Then the command exits with code 0 - And the system should return a response in JSON format And all wells are imported + @positive @validation @BDMS-TBD + Scenario: Upload treats Complete monitoring_frequency as not currently monitored + Given my CSV file contains a row with monitoring_frequency set to "Complete" + When I run the well inventory bulk upload command + Then the command exits with code 0 + And all wells are imported + And the imported well with monitoring_frequency "Complete" is marked not currently monitored + @positive @validation @autogenerate_ids @BDMS-TBD Scenario: Upload succeeds and system auto-generates well_name_point_id for uppercase prefix placeholders and blanks Given my CSV file contains all valid columns but uses uppercase "-xxxx" placeholders and blank values for well_name_point_id When I run the well inventory bulk upload command Then the command exits with code 0 - And the system should return a response in JSON format And all wells are imported with system-generated unique well_name_point_id values ########################################################################### @@ -199,97 +205,142 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains 3 rows of data with 2 valid rows and 1 row with a blank "well_name_point_id" When I run the well inventory bulk upload command Then the command exits with code 0 - And the system should return a response in JSON format And all wells are imported with system-generated unique well_name_point_id values @negative @validation @BDMS-TBD Scenario: Upload fails when a row has an invalid postal code format - Given my CSV file contains a row that has an invalid postal code format in contact_1_address_1_postal_code + Given my CSV file contains a row that has an invalid postal code format in contact_1_address_1_postal_code When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the invalid postal code format - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has a contact with a invalid phone number format + Scenario: Upload fails when a row has a contact with an invalid phone number format Given my CSV file contains a row with a contact with a phone number that is not in the valid format When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the invalid phone number format - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has a contact with a invalid email format + Scenario: Upload fails when a row has a contact with an invalid email format Given my CSV file contains a row with a contact with an email that is not in the valid format When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the invalid email format - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has contact without a contact_role + Scenario: Upload fails when a row has a contact without a "contact_role" Given my CSV file contains a row with a contact but is missing the required "contact_role" field for that contact When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format - And the response includes a validation error indicating the missing "contact_role" field - And no wells are imported + And the response includes a validation error indicating the missing "contact_role" value + And 1 well is imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has contact without a "contact_type" + Scenario: Upload fails when a row has a contact without a "contact_type" Given my CSV file contains a row with a contact but is missing the required "contact_type" field for that contact When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the missing "contact_type" value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has contact with an invalid "contact_type" + Scenario: Upload fails when a row has a contact with an invalid "contact_type" Given my CSV file contains a row with a contact_type value that is not in the valid lexicon for "contact_type" When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating an invalid "contact_type" value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has contact with an email without an email_type + Scenario: Upload fails when a row has a contact with an email without an email_type Given my CSV file contains a row with a contact with an email but is missing the required "email_type" field for that email When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the missing "email_type" value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has contact with a phone without a phone_type + Scenario: Upload fails when a row has a contact with a phone without a phone_type Given my CSV file contains a row with a contact with a phone but is missing the required "phone_type" field for that phone When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the missing "phone_type" value - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD - Scenario: Upload fails when a row has contact with an address without an address_type + Scenario: Upload fails when a row has a contact with an address without an address_type Given my CSV file contains a row with a contact with an address but is missing the required "address_type" field for that address When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the missing "address_type" value - And no wells are imported + And 1 well is imported + + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has a contact with an invalid "address_type" + Given my CSV file contains a row with an address_type value that is not one of: Work, Personal, Mailing, Physical + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the response includes a validation error indicating an invalid "address_type" value + And 1 well is imported + + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has a contact with an invalid state abbreviation + Given my CSV file contains a row with a state value that is not a valid 2-letter US state abbreviation + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the response includes a validation error indicating an invalid state value + And 1 well is imported + + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has an invalid well_hole_status value + Given my CSV file contains a row with a well_hole_status value that is not one of: "Abandoned", "Active, pumping well", "Destroyed, exists but not usable", "Inactive, exists but not used" + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the response includes a validation error indicating an invalid "well_hole_status" value + And 1 well is imported + + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has an invalid monitoring_status value + Given my CSV file contains a row with a monitoring_status value that is not one of: "Open", "Open (unequipped)", "Closed", "Datalogger can be installed", "Datalogger cannot be installed", "Abandoned", "Active, pumping well", "Destroyed, exists but not usable", "Inactive, exists but not used", "Currently monitored", "Not currently monitored" + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the response includes a validation error indicating an invalid "monitoring_status" value + And 1 well is imported + + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has an invalid well_pump_type value + Given my CSV file contains a row with a well_pump_type value that is not one of: "Submersible", "Jet", "Line Shaft", "Hand" + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the response includes a validation error indicating an invalid "well_pump_type" value + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when a row has utm_easting utm_northing and utm_zone values that are not within New Mexico Given my CSV file contains a row with utm_easting utm_northing and utm_zone values that are not within New Mexico When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating the invalid UTM coordinates + And 1 well is imported + + @negative @validation @BDMS-TBD + Scenario: Upload fails when a row has a contact with neither contact_name nor contact_organization + Given my CSV file contains a row with contact fields filled but both "contact_1_name" and "contact_1_organization" are blank + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the response includes a validation error indicating that at least one of "contact_1_name" or "contact_1_organization" must be provided + And 1 well is imported + + @negative @validation @BDMS-TBD + Scenario: Upload fails when water_level_date_time is missing but depth_to_water_ft is provided + Given my CSV file contains a row where "depth_to_water_ft" is filled but "water_level_date_time" is blank + When I run the well inventory bulk upload command + Then the command exits with a non-zero exit code + And the response includes a validation error indicating that "water_level_date_time" is required when "depth_to_water_ft" is provided And no wells are imported @negative @validation @required_fields @BDMS-TBD @@ -297,7 +348,6 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a row missing the required "" field When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error for the "" field And no wells are imported @@ -305,15 +355,11 @@ Feature: Bulk upload well inventory from CSV via CLI | required_field | | project | | well_name_point_id | - | site_name | | date_time | | field_staff | | utm_easting | | utm_northing | | utm_zone | - | elevation_ft | - | elevation_method | - | measuring_point_height_ft | @negative @validation @boolean_fields @BDMS-TBD Scenario: Upload fails due to invalid boolean field values @@ -321,9 +367,8 @@ Feature: Bulk upload well inventory from CSV via CLI # And my CSV file contains other boolean fields such as "sample_possible" with valid boolean values When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating an invalid boolean value for the "is_open" field - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails when duplicate well_name_point_id values are present @@ -332,7 +377,7 @@ Feature: Bulk upload well inventory from CSV via CLI Then the command exits with a non-zero exit code And the response includes validation errors indicating duplicated values And each error identifies the row and field - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails due to invalid lexicon values @@ -340,7 +385,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes validation errors identifying the invalid field and row - And no wells are imported + And 3 wells are imported @negative @validation @BDMS-TBD Scenario: Upload fails due to invalid date formats @@ -348,7 +393,7 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes validation errors identifying the invalid field and row - And no wells are imported + And 1 well is imported @negative @validation @BDMS-TBD Scenario: Upload fails due to invalid numeric fields @@ -356,19 +401,18 @@ Feature: Bulk upload well inventory from CSV via CLI When I run the well inventory bulk upload command Then the command exits with a non-zero exit code And the response includes validation errors identifying the invalid field and row - And no wells are imported + And 3 wells are imported -# ########################################################################### -# # FILE FORMAT SCENARIOS -# ########################################################################### + ########################################################################### + # FILE FORMAT SCENARIOS + ########################################################################### @negative @file_format @limits @BDMS-TBD Scenario: Upload fails when the CSV exceeds the maximum allowed number of rows Given my CSV file contains more rows than the configured maximum for bulk upload When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes an error message indicating the row limit was exceeded And no wells are imported @@ -405,21 +449,17 @@ Feature: Bulk upload well inventory from CSV via CLI Given my CSV file contains a valid but duplicate header row When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating a repeated header row - And no wells are imported - + And 3 wells are imported @negative @validation @header_row @BDMS-TBD Scenario: Upload fails when the header row contains duplicate column names Given my CSV file header row contains the "contact_1_email_1" column name more than once When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes a validation error indicating duplicate header names And no wells are imported - ########################################################################### # DELIMITER & QUOTING / EXCEL-RELATED SCENARIOS ########################################################################### @@ -430,7 +470,6 @@ Feature: Bulk upload well inventory from CSV via CLI And my file uses "" as the field delimiter instead of commas When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format And the response includes an error message indicating an unsupported delimiter And no wells are imported @@ -446,29 +485,18 @@ Feature: Bulk upload well inventory from CSV via CLI # And all other required fields are populated with valid values When I run the well inventory bulk upload command Then the command exits with code 0 - And the system should return a response in JSON format And all wells are imported -# -# @negative @validation @numeric @excel @BDMS-TBD -# Scenario: Upload fails when numeric fields are provided in Excel scientific notation format -# Given my CSV file contains a numeric-required field such as "utm_easting" -# And Excel has exported the "utm_easting" value in scientific notation (for example "1.2345E+06") -# When I run the well inventory bulk upload command -# Then the command exits with a non-zero exit code -# And the system should return a response in JSON format -# And the response includes a validation error indicating an invalid numeric format for "utm_easting" -# And no wells are imported - -########################################################################### + + ########################################################################### # WATER LEVEL ENTRY VALIDATION -########################################################################### + ########################################################################### - # if one water level entry field is filled, then all are required + # water_level_date_time is required only when depth_to_water_ft is provided + # all other water level fields are optional and independent @negative @validation @BDMS-TBD - Scenario: Water level entry fields are all required if any are filled - Given my csv file contains a row where some but not all water level entry fields are filled + Scenario: Upload fails when depth_to_water_ft is provided but water_level_date_time is missing + Given my csv file contains a row where "depth_to_water_ft" is filled but "water_level_date_time" is blank When I run the well inventory bulk upload command Then the command exits with a non-zero exit code - And the system should return a response in JSON format - And the response includes validation errors for each missing water level entry field + And the response includes a validation error indicating that "water_level_date_time" is required when "depth_to_water_ft" is provided And no wells are imported diff --git a/tests/integration/test_alembic_migrations.py b/tests/integration/test_alembic_migrations.py index 92036c77..5bba21c2 100644 --- a/tests/integration/test_alembic_migrations.py +++ b/tests/integration/test_alembic_migrations.py @@ -223,6 +223,31 @@ def test_postgis_extension_enabled(self): assert postgis == "postgis", "PostGIS extension not enabled" + def test_water_elevation_materialized_view_has_expected_columns(self): + """Water elevation materialized view should match the feet-normalized schema.""" + with session_ctx() as session: + result = session.execute(text(""" + SELECT attname + FROM pg_attribute + WHERE attrelid = 'ogc_water_elevation_wells'::regclass + AND attnum > 0 + AND NOT attisdropped + ORDER BY attnum + """)) + columns = [row[0] for row in result.fetchall()] + + assert columns == [ + "id", + "name", + "thing_type", + "observation_id", + "observation_datetime", + "elevation_m", + "depth_to_water_below_ground_surface_ft", + "water_elevation_ft", + "point", + ] + # ============================================================================= # Foreign Key Integrity Tests diff --git a/tests/test_asset.py b/tests/test_asset.py index 008cade9..081fe580 100644 --- a/tests/test_asset.py +++ b/tests/test_asset.py @@ -13,6 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +import io +import logging +import os from datetime import timezone from unittest.mock import patch @@ -23,6 +26,7 @@ from core.dependencies import viewer_function, admin_function, editor_function from db import Asset from schemas import DT_FMT +from services import gcs_helper from tests import ( client, cleanup_post_test, @@ -30,12 +34,18 @@ cleanup_patch_test, ) -# CLASSES, FIXTURES, AND FUNCTIONS ============================================= +# CLASSES, FIXTURES, AND FUNCTIONS =========================================== class MockBlob: + def __init__(self): + self.upload_calls = 0 + self.last_file_position = None + def upload_from_file(self, *args, **kwargs): - pass + self.upload_calls += 1 + if args: + self.last_file_position = args[0].tell() def generate_signed_url(self, *args, **kwargs): return "https://storage.googleapis.com/mock-bucket/mock-asset" @@ -47,11 +57,15 @@ def delete(self, *args, **kwargs): class MockStorageBucket: name = "mock-bucket" + def __init__(self, existing_blob=None): + self._blob = MockBlob() + self._existing_blob = existing_blob + def blob(self, *args, **kwargs): - return MockBlob() + return self._blob def get_blob(self, *args, **kwargs): - return None + return self._existing_blob def mock_storage_bucket(): @@ -77,7 +91,7 @@ def override_dependency_fixture(): app.dependency_overrides = {} -# POST & UPLOAD tests ========================================================== +# POST & UPLOAD tests ======================================================== def test_upload_asset(): @@ -94,6 +108,86 @@ def test_upload_asset(): assert "storage_path" in data +def test_gcs_upload_logs_stage_timings(caplog): + bucket = MockStorageBucket() + upload = type( + "UploadStub", + (), + { + "filename": "field-compilation.pdf", + "content_type": "application/pdf", + "file": io.BytesIO(b"pdf-bytes" * 2048), + }, + )() + + with patch.dict(os.environ, {"API_DEBUG_TIMING": "true"}): + with caplog.at_level(logging.INFO, logger="services.gcs_helper"): + uri, blob_name = gcs_helper.gcs_upload(upload, bucket) + + stage_logs = [ + record for record in caplog.records if record.msg == "gcs stage timing" + ] + + assert uri.endswith(blob_name) + assert {record.stage for record in stage_logs} >= { + "hash_file", + "lookup_blob", + "upload_blob", + "upload_request_total", + } + + +def test_gcs_upload_skips_existing_blob(): + existing_blob = object() + bucket = MockStorageBucket(existing_blob=existing_blob) + upload = type( + "UploadStub", + (), + { + "filename": "existing.pdf", + "content_type": "application/pdf", + "file": io.BytesIO(b"existing-pdf"), + }, + )() + + gcs_helper.gcs_upload(upload, bucket) + + assert bucket._blob.upload_calls == 0 + + +def test_make_blob_name_and_uri_rewinds_file_after_hashing(): + upload = type( + "UploadStub", + (), + { + "filename": "rewind.pdf", + "file": io.BytesIO(b"a" * (gcs_helper.HASH_CHUNK_SIZE + 5)), + }, + )() + + blob_name, uri = gcs_helper.make_blob_name_and_uri(upload) + + assert blob_name in uri + assert upload.file.tell() == 0 + + +def test_gcs_upload_rewinds_before_upload(): + bucket = MockStorageBucket() + upload = type( + "UploadStub", + (), + { + "filename": "rewind-before-upload.pdf", + "content_type": "application/pdf", + "file": io.BytesIO(b"b" * (gcs_helper.HASH_CHUNK_SIZE + 7)), + }, + )() + + gcs_helper.gcs_upload(upload, bucket) + + assert bucket._blob.last_file_position == 0 + + def test_add_asset(water_well_thing): payload = { "release_status": "draft", @@ -119,7 +213,7 @@ def test_add_asset(water_well_thing): assert data["storage_path"] == payload["storage_path"] assert data["mime_type"] == payload["mime_type"] assert data["size"] == payload["size"] - assert data["signed_url"] == None + assert data["signed_url"] is None cleanup_post_test(Asset, data["id"]) @@ -146,7 +240,7 @@ def test_add_asset_409_bad_thing_id(water_well_thing): assert data["detail"][0]["input"] == {"thing_id": bad_thing_id} -# GET tests ==================================================================== +# GET tests ================================================================== def test_get_assets(asset, asset_with_associated_thing): @@ -166,7 +260,7 @@ def test_get_assets(asset, asset_with_associated_thing): assert data["items"][0]["size"] == asset.size assert data["items"][0]["uri"] == asset.uri assert data["items"][0]["storage_service"] == asset.storage_service - assert data["items"][0]["signed_url"] == None + assert data["items"][0]["signed_url"] is None assert data["items"][1]["id"] == asset_with_associated_thing.id assert data["items"][1][ @@ -187,11 +281,14 @@ def test_get_assets(asset, asset_with_associated_thing): data["items"][1]["storage_service"] == asset_with_associated_thing.storage_service ) - assert data["items"][1]["signed_url"] == None + assert data["items"][1]["signed_url"] is None def test_get_assets_thing_id(asset_with_associated_thing, water_well_thing): - with patch("api.asset.get_storage_bucket", return_value=MockStorageBucket()): + with patch( + "api.asset.get_storage_bucket", + return_value=MockStorageBucket(), + ): query_parameters = {"thing_id": water_well_thing.id} response = client.get("/asset", params=query_parameters) assert response.status_code == 200 @@ -231,7 +328,7 @@ def test_get_asset_by_id_404_not_found(asset): assert data["detail"] == f"Asset with ID {bad_id} not found." -# PATCH tests ================================================================== +# PATCH tests ================================================================ def test_patch_asset(asset): @@ -260,7 +357,7 @@ def test_patch_asset_404_not_found(asset): assert data["detail"] == f"Asset with ID {bad_id} not found." -# DELETE tests ================================================================= +# DELETE tests =============================================================== def test_delete_asset(second_asset): diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 0673d8ba..a1d4515f 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -15,9 +15,11 @@ # =============================================================================== from __future__ import annotations +import gzip import textwrap import uuid from pathlib import Path +from subprocess import CalledProcessError from types import SimpleNamespace from sqlalchemy import select @@ -55,15 +57,20 @@ def __exit__(self, exc_type, exc, tb): assert result.exit_code == 0, result.output assert executed_sql == [ "REFRESH MATERIALIZED VIEW ogc_latest_depth_to_water_wells", + "REFRESH MATERIALIZED VIEW ogc_water_elevation_wells", "REFRESH MATERIALIZED VIEW ogc_avg_tds_wells", "REFRESH MATERIALIZED VIEW ogc_depth_to_water_trend_wells", "REFRESH MATERIALIZED VIEW ogc_water_well_summary", + "REFRESH MATERIALIZED VIEW ogc_major_chemistry_results", + "REFRESH MATERIALIZED VIEW ogc_minor_chemistry_wells", ] assert commit_called["value"] is True - assert "Refreshed 4 materialized view(s)." in result.output + assert "Refreshed 7 materialized view(s)." in result.output -def test_refresh_pygeoapi_materialized_views_custom_and_concurrently(monkeypatch): +def test_refresh_pygeoapi_materialized_views_custom_and_concurrently( + monkeypatch, +): executed_sql: list[str] = [] execution_options: list[dict[str, object]] = [] @@ -120,6 +127,111 @@ def test_refresh_pygeoapi_materialized_views_rejects_invalid_identifier(): assert "Invalid SQL identifier" in result.output +def test_import_project_area_boundaries_updates_matching_groups(monkeypatch): + class FakeGroup: + def __init__(self): + self.project_area = None + + fake_group = FakeGroup() + + class FakeClient: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("cli.project_area_import.httpx.Client", FakeClient) + monkeypatch.setattr( + "cli.project_area_import._fetch_project_area_features", + lambda client, layer_url: [ + { + "properties": {"location": "Test Group"}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-106.9, 33.9], + [-106.7, 33.9], + [-106.7, 34.1], + [-106.9, 34.1], + [-106.9, 33.9], + ] + ], + }, + }, + { + "properties": {"location": "Missing Group"}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-105.0, 33.0], + [-104.8, 33.0], + [-104.8, 33.2], + [-105.0, 33.2], + [-105.0, 33.0], + ] + ], + }, + }, + ], + ) + + class FakeScalarResult: + def __init__(self, groups): + self._groups = groups + + def all(self): + return self._groups + + class FakeSession: + def __init__(self): + self.commit_called = False + self.scalar_calls = 0 + self.added = [] + + def scalars(self, stmt): + self.scalar_calls += 1 + if self.scalar_calls == 1: + return FakeScalarResult([fake_group]) + return FakeScalarResult([]) + + def add(self, obj): + self.added.append(obj) + + def commit(self): + self.commit_called = True + + class FakeSessionCtx: + def __enter__(self): + self.session = FakeSession() + return self.session + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr( + "cli.project_area_import.session_ctx", + lambda: FakeSessionCtx(), + ) + + runner = CliRunner() + result = runner.invoke(cli, ["import-project-area-boundaries"]) + + assert result.exit_code == 0, result.output + assert "Fetched 2 feature(s)." in result.output + assert "Matched 2 group row(s)." in result.output + assert "Created 1 group(s)." in result.output + assert "Updated 1 group project area(s)." in result.output + assert "Skipped 0 unchanged group(s)." in result.output + assert "Unmatched locations: Missing Group" in result.output + assert fake_group.project_area is not None + + def test_initialize_lexicon_invokes_initializer(monkeypatch): called = {"count": 0} @@ -156,6 +268,201 @@ def fake_associate(source_directory): assert captured["path"] == asset_dir +def test_restore_local_db_invokes_psql(monkeypatch, tmp_path): + sql_file = tmp_path / "restore.sql" + sql_file.write_text( + "SET ROLE ocotillo;\n" + "ALTER TABLE public.sample OWNER TO ocotillo;\n" + "GRANT ALL ON TABLE public.sample TO ocotillo;\n" + "select 1;\n" + ) + captured: dict[str, object] = {} + call_order: list[str] = [] + + def fake_reset(): + call_order.append("reset") + + def fake_run(command, check, env, capture_output, text): + call_order.append("psql") + captured["command"] = command + captured["check"] = check + captured["env"] = env + captured["capture_output"] = capture_output + captured["text"] = text + captured["restored_sql"] = Path(command[-1]).read_text() + return SimpleNamespace(returncode=0) + + monkeypatch.setattr("cli.db_restore._reset_target_schema", fake_reset) + monkeypatch.setattr("cli.db_restore.subprocess.run", fake_run) + monkeypatch.setenv("POSTGRES_HOST", "localhost") + monkeypatch.setenv("POSTGRES_PORT", "5432") + monkeypatch.setenv("POSTGRES_USER", "nm_user") + monkeypatch.setenv("POSTGRES_PASSWORD", "secret") + monkeypatch.setenv("POSTGRES_DB", "ocotilloapi_dev") + + runner = CliRunner() + result = runner.invoke(cli, ["restore-local-db", str(sql_file)]) + + assert result.exit_code == 0, result.output + assert captured["command"][:-1] == [ + "psql", + "-v", + "ON_ERROR_STOP=1", + "-h", + "localhost", + "-p", + "5432", + "-U", + "nm_user", + "-d", + "ocotilloapi_dev", + "-f", + ] + assert captured["command"][-1].endswith("/restore.sql") + assert captured["check"] is True + assert captured["capture_output"] is True + assert captured["text"] is True + assert captured["env"]["PGPASSWORD"] == "secret" + assert captured["restored_sql"] == "select 1;\n" + assert call_order == ["reset", "psql"] + assert "Restored" in result.output + assert "ocotilloapi_dev" in result.output + + +def test_restore_local_db_rejects_non_sql_files(tmp_path): + source_file = tmp_path / "restore.dump" + source_file.write_text("not sql") + + runner = CliRunner() + result = runner.invoke(cli, ["restore-local-db", str(source_file)]) + + assert result.exit_code == 1 + assert "requires a .sql or .sql.gz source" in result.output + + +def test_restore_local_db_rejects_remote_host(monkeypatch, tmp_path): + sql_file = tmp_path / "restore.sql" + sql_file.write_text("select 1;\n") + called = {"value": False} + + def fake_run(*args, **kwargs): + called["value"] = True + raise AssertionError("subprocess.run should not be called for remote hosts") + + monkeypatch.setattr("cli.db_restore.subprocess.run", fake_run) + monkeypatch.setenv("POSTGRES_HOST", "db.example.com") + + runner = CliRunner() + result = runner.invoke(cli, ["restore-local-db", str(sql_file)]) + + assert result.exit_code == 1 + assert "only supports local PostgreSQL hosts" in result.output + assert called["value"] is False + + +def test_restore_local_db_reports_psql_failures(monkeypatch, tmp_path): + sql_file = tmp_path / "restore.sql" + sql_file.write_text("select 1;\n") + + def fake_run(command, check, env, capture_output, text): + raise CalledProcessError( + 1, + command, + stderr='psql: role "missing" does not exist', + ) + + monkeypatch.setattr("cli.db_restore._reset_target_schema", lambda: None) + monkeypatch.setattr("cli.db_restore.subprocess.run", fake_run) + monkeypatch.setenv("POSTGRES_HOST", "localhost") + monkeypatch.setenv("POSTGRES_DB", "ocotilloapi_dev") + + runner = CliRunner() + result = runner.invoke(cli, ["restore-local-db", str(sql_file)]) + + assert result.exit_code == 1 + assert "Restore failed for database 'ocotilloapi_dev'" in result.output + assert 'role "missing" does not exist' in result.output + + +def test_restore_local_db_downloads_and_restores_gcs_gzip(monkeypatch, tmp_path): + source_uri = "gs://ocotillo/sql-exports/latest.sql.gz" + sql_text = ( + "SET SESSION AUTHORIZATION 'ocotillo';\n" + "REVOKE ALL ON SCHEMA public FROM ocotillo;\n" + "select 42;\n" + ) + gz_payload = gzip.compress(sql_text.encode("utf-8")) + captured: dict[str, object] = {} + + class FakeBlob: + def download_to_filename(self, filename): + Path(filename).write_bytes(gz_payload) + + class FakeBucket: + def __init__(self): + self.requested_blob_name = None + + def blob(self, blob_name): + self.requested_blob_name = blob_name + captured["blob_name"] = blob_name + return FakeBlob() + + fake_bucket = FakeBucket() + + def fake_get_storage_bucket(client=None, bucket=None): + captured["bucket_name"] = bucket + return fake_bucket + + def fake_run(command, check, env, capture_output, text): + captured["command"] = command + captured["restored_sql"] = Path(command[-1]).read_text() + return SimpleNamespace(returncode=0) + + monkeypatch.setattr("cli.db_restore._reset_target_schema", lambda: None) + monkeypatch.setattr( + "cli.db_restore.get_storage_bucket", + fake_get_storage_bucket, + ) + monkeypatch.setattr("cli.db_restore.subprocess.run", fake_run) + monkeypatch.setenv("POSTGRES_HOST", "localhost") + monkeypatch.setenv("POSTGRES_DB", "ocotilloapi_dev") + + runner = CliRunner() + result = runner.invoke(cli, ["restore-local-db", source_uri]) + + assert result.exit_code == 0, result.output + assert captured["bucket_name"] == "ocotillo" + assert captured["blob_name"] == "sql-exports/latest.sql.gz" + assert captured["restored_sql"] == "select 42;\n" + assert captured["command"][-2:] == ["-f", captured["command"][-1]] + assert source_uri in result.output + + +def test_restore_local_db_reports_schema_reset_failures(monkeypatch, tmp_path): + sql_file = tmp_path / "restore.sql" + sql_file.write_text("select 1;\n") + called = {"psql": False} + + def fake_reset(): + raise RuntimeError("permission denied to drop schema public") + + def fake_run(*args, **kwargs): + called["psql"] = True + raise AssertionError("psql should not be called when schema reset fails") + + monkeypatch.setattr("cli.db_restore._reset_target_schema", fake_reset) + monkeypatch.setattr("cli.db_restore.subprocess.run", fake_run) + monkeypatch.setenv("POSTGRES_HOST", "localhost") + monkeypatch.setenv("POSTGRES_DB", "ocotilloapi_dev") + + runner = CliRunner() + result = runner.invoke(cli, ["restore-local-db", str(sql_file)]) + + assert result.exit_code == 1 + assert "permission denied to drop schema public" in result.output + assert called["psql"] is False + + def test_well_inventory_csv_command_calls_service(monkeypatch, tmp_path): inventory_file = tmp_path / "inventory.csv" inventory_file.write_text("header\nvalue\n") @@ -178,7 +485,10 @@ def fake_well_inventory(file_path): }, ) - monkeypatch.setattr("cli.service_adapter.well_inventory_csv", fake_well_inventory) + monkeypatch.setattr( + "cli.service_adapter.well_inventory_csv", + fake_well_inventory, + ) runner = CliRunner() result = runner.invoke(cli, ["well-inventory-csv", str(inventory_file)]) @@ -207,7 +517,8 @@ def write_summary(path, comparison): captured["result_count"] = len(comparison.results) monkeypatch.setattr( - "transfers.transfer_results_builder.TransferResultsBuilder", FakeBuilder + "transfers.transfer_results_builder.TransferResultsBuilder", + FakeBuilder, ) summary_path = tmp_path / "metrics" / "summary.md" @@ -265,7 +576,10 @@ def fake_well_inventory(_file_path): }, ) - monkeypatch.setattr("cli.service_adapter.well_inventory_csv", fake_well_inventory) + monkeypatch.setattr( + "cli.service_adapter.well_inventory_csv", + fake_well_inventory, + ) runner = CliRunner() result = runner.invoke(cli, ["well-inventory-csv", str(inventory_file)]) @@ -329,15 +643,62 @@ def fake_upload(file_path, *, pretty_json=False): assert captured["pretty_json"] is True +def test_water_levels_bulk_upload_reports_partial_success(monkeypatch, tmp_path): + csv_file = tmp_path / "water_levels.csv" + csv_file.write_text("col\nvalue\n") + + def fake_upload(_file_path, *, pretty_json=False): + assert pretty_json is False + return SimpleNamespace( + exit_code=0, + stdout="", + stderr="Row 2: Unknown well_name_point_id 'Bad Well'", + payload={ + "summary": { + "total_rows_processed": 2, + "total_rows_imported": 1, + "validation_errors_or_warnings": 1, + }, + "validation_errors": ["Row 2: Unknown well_name_point_id 'Bad Well'"], + "water_levels": [{}], + }, + ) + + monkeypatch.setattr("cli.service_adapter.water_levels_csv", fake_upload) + + runner = CliRunner() + result = runner.invoke( + cli, ["water-levels", "bulk-upload", "--file", str(csv_file)] + ) + + assert result.exit_code == 0, result.output + assert "[WATER LEVEL IMPORT] COMPLETED WITH ISSUES" in result.output + assert "rows_with_issues" in result.output + + def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ - End-to-end CLI invocation should create FieldEvent, Sample, and Observation rows. + End-to-end CLI invocation should create FieldEvent, Sample, + and Observation rows. """ def _write_csv(path: Path, *, well_name: str, notes: str): + header = ( + "field_staff,well_name_point_id,field_event_date_time," + "measurement_date_time,sampler,sample_method,mp_height," + "level_status,depth_to_water_ft,data_quality," + "water_level_notes" + ) + row = ( + f"CLI Tester,{well_name},2025-02-15T08:00:00-07:00," + "2025-02-15T10:30:00-07:00,CLI Tester,electric tape," + f"1.5,Water level not affected,7.0," + "Water level accurate to within two hundreths of a foot," + f"{notes}" + ) csv_text = textwrap.dedent(f"""\ - field_staff,well_name_point_id,field_event_date_time,measurement_date_time,sampler,sample_method,mp_height,level_status,depth_to_water_ft,data_quality,water_level_notes - CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} + {header} + {row} """) path.write_text(csv_text) @@ -371,10 +732,16 @@ def _write_csv(path: Path, *, well_name: str, notes: str): assert field_event.thing_id == water_well_thing.id assert sample.sample_method == "Electric tape measurement (E-probe)" - assert sample.sample_matrix == "water" - assert observation.value == 42.5 + assert sample.sample_matrix == "groundwater" + assert sample.sample_name == f"{water_well_thing.name}-WL-202502151730" + assert observation.value == 7.0 assert observation.measuring_point_height == 1.5 - assert observation.notes == "Level status: stable | Data quality: approved" + assert observation.notes == unique_notes + assert observation.groundwater_level_reason == "Water level not affected" + assert ( + observation.nma_data_quality + == "Water level accurate to within two hundreths of a foot" + ) assert ( field_event.notes == f"Field staff: CLI Tester | {unique_notes}" ), "Field event notes should capture field staff and notes" diff --git a/tests/test_lazy_admin.py b/tests/test_lazy_admin.py new file mode 100644 index 00000000..5b70ed88 --- /dev/null +++ b/tests/test_lazy_admin.py @@ -0,0 +1,19 @@ +import os + +from core.factory import create_api_app +from fastapi.testclient import TestClient + + +def test_admin_is_lazy_loaded_on_first_admin_request(): + os.environ["SESSION_SECRET_KEY"] = "test-session-secret-key" + app = create_api_app() + + assert not any(route.path.startswith("/admin") for route in app.routes) + assert getattr(app.state, "admin_configured", False) is False + + with TestClient(app) as client: + response = client.get("/admin", follow_redirects=False) + + assert response.status_code in {200, 302, 307} + assert app.state.admin_configured is True + assert any(route.path.startswith("/admin") for route in app.routes) diff --git a/tests/test_location.py b/tests/test_location.py index 8dda23a4..e849d297 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -161,32 +161,33 @@ def test_get_locations(location): response = client.get("/location") assert response.status_code == 200 data = response.json() - assert data["total"] == 1 - assert data["items"][0]["id"] == location.id - assert data["items"][0]["created_at"] == location.created_at.astimezone( - timezone.utc - ).strftime(DT_FMT) + assert data["total"] >= 1 + item = next((item for item in data["items"] if item["id"] == location.id), None) + assert item is not None + assert item["created_at"] == location.created_at.astimezone(timezone.utc).strftime( + DT_FMT + ) # assert data["items"][0]["name"] == location.name - assert isinstance(data["items"][0]["notes"], list) + assert isinstance(item["notes"], list) # If you know the exact number of notes expected: # assert len(data["items"][0]["notes"]) == expected_count # If you want to check content of a specific note: # if data["items"][0]["notes"]: # assert data["items"][0]["notes"][0]["content"] == expected_content - assert data["items"][0]["point"] == to_shape(location.point).wkt - assert data["items"][0]["elevation"] == location.elevation - assert data["items"][0]["release_status"] == location.release_status - assert "nma_location_notes" in data["items"][0] - assert data["items"][0]["nma_location_notes"] == location.nma_location_notes - assert "nma_data_reliability" in data["items"][0] - assert data["items"][0]["nma_data_reliability"] == location.nma_data_reliability + assert item["point"] == to_shape(location.point).wkt + assert item["elevation"] == location.elevation + assert item["release_status"] == location.release_status + assert "nma_location_notes" in item + assert item["nma_location_notes"] == location.nma_location_notes + assert "nma_data_reliability" in item + assert item["nma_data_reliability"] == location.nma_data_reliability # assert data["items"][0]["elevation_accuracy"] == location.elevation_accuracy # assert data["items"][0]["elevation_method"] == location.elevation_method # assert data["items"][0]["coordinate_accuracy"] == location.coordinate_accuracy # assert data["items"][0]["coordinate_method"] == location.coordinate_method - assert data["items"][0]["state"] == location.state - assert data["items"][0]["county"] == location.county - assert data["items"][0]["quad_name"] == location.quad_name + assert item["state"] == location.state + assert item["county"] == location.county + assert item["quad_name"] == location.quad_name def test_get_location_by_id(location): diff --git a/tests/test_observation.py b/tests/test_observation.py index daad2678..be43b808 100644 --- a/tests/test_observation.py +++ b/tests/test_observation.py @@ -14,7 +14,8 @@ # limitations under the License. # =============================================================================== -from datetime import timezone +from datetime import datetime, timedelta, timezone +import uuid import pytest @@ -25,7 +26,18 @@ amp_editor_function, viewer_function, ) -from db import Observation, FieldEvent, FieldActivity, Sample +from db import ( + Deployment, + FieldActivity, + FieldEvent, + LocationThingAssociation, + Observation, + Sample, + Sensor, + Thing, + TransducerObservation, + TransducerObservationBlock, +) from db.engine import session_ctx from main import app from schemas import DT_FMT @@ -149,12 +161,12 @@ def test_bulk_upload_groundwater_levels_api(water_well_thing): water_well_thing.name, "2025-02-15T08:00:00-07:00", "2025-02-15T10:30:00-07:00", - "Groundwater Team", + "A Lopez", "electric tape", "1.5", - "stable", - "45.2", - "approved", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", "Initial measurement", ] ) @@ -168,18 +180,30 @@ def test_bulk_upload_groundwater_levels_api(water_well_thing): assert response.status_code == 200 assert data["summary"]["total_rows_imported"] == 1 assert data["summary"]["total_rows_processed"] == 1 - assert data["summary"]["validation_errors_or_warnings"] == 0 - assert data["validation_errors"] == [] + assert data["summary"]["validation_errors_or_warnings"] == 1 + assert data["validation_errors"] == [ + "Row 1: CSV mp_height (1.5) differs from existing measuring point height " + "(2.0); CSV value will be used" + ] row = data["water_levels"][0] assert row["well_name_point_id"] == water_well_thing.name with session_ctx() as session: observation = session.get(Observation, row["observation_id"]) assert observation is not None + sample = session.get(Sample, row["sample_id"]) + assert sample is not None + assert sample.sample_name == f"{water_well_thing.name}-WL-202502151730" + assert sample.sample_matrix == "groundwater" + assert observation.groundwater_level_reason == "Water level not affected" + assert ( + observation.nma_data_quality + == "Water level accurate to within two hundreths of a foot" + ) + assert observation.measuring_point_height == 1.5 # cleanup in reverse dependency order if observation: session.delete(observation) - sample = session.get(Sample, row["sample_id"]) if sample: session.delete(sample) field_activity = session.get(FieldActivity, row["field_activity_id"]) @@ -191,6 +215,94 @@ def test_bulk_upload_groundwater_levels_api(water_well_thing): session.commit() +def test_bulk_upload_groundwater_levels_api_partial_success(water_well_thing): + csv_content = ",".join( + [ + "field_staff", + "well_name_point_id", + "field_event_date_time", + "measurement_date_time", + "sampler", + "sample_method", + "mp_height", + "level_status", + "depth_to_water_ft", + "data_quality", + "water_level_notes", + ] + ) + csv_content += "\n" + csv_content += "\n".join( + [ + ",".join( + [ + "A Lopez", + water_well_thing.name, + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Initial measurement", + ] + ), + ",".join( + [ + "A Lopez", + "Bad Well", + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Bad row", + ] + ), + ] + ) + + files = { + "file": ("water_levels.csv", csv_content, "text/csv"), + } + + response = client.post("/observation/groundwater-level/bulk-upload", files=files) + data = response.json() + assert response.status_code == 200 + assert data["summary"]["total_rows_imported"] == 1 + assert data["summary"]["total_rows_processed"] == 2 + assert data["summary"]["validation_errors_or_warnings"] == 2 + assert len(data["validation_errors"]) == 2 + assert any( + "CSV mp_height (1.5) differs from existing measuring point height (2.0)" + in message + for message in data["validation_errors"] + ) + assert any("Bad Well" in message for message in data["validation_errors"]) + + row = data["water_levels"][0] + with session_ctx() as session: + observation = session.get(Observation, row["observation_id"]) + sample = session.get(Sample, row["sample_id"]) + field_activity = session.get(FieldActivity, row["field_activity_id"]) + field_event = session.get(FieldEvent, row["field_event_id"]) + + if observation: + session.delete(observation) + if sample: + session.delete(sample) + if field_activity: + session.delete(field_activity) + if field_event: + session.delete(field_event) + session.commit() + + # PATCH tests ================================================================== @@ -384,6 +496,162 @@ def test_get_groundwater_level_observations(groundwater_level_observation): ) +def test_get_transducer_groundwater_level_observations_uses_blocks_for_same_thing( + location, second_location, sensor +): + observation_time = datetime.now(timezone.utc) + matching_block_id = None + observation_id = None + other_block_id = None + target_deployment_id = None + other_deployment_id = None + other_sensor_id = None + other_thing_id = None + target_thing_id = None + + try: + with session_ctx() as session: + target_thing = Thing( + name="Transducer Target Well", + first_visit_date="2023-03-03", + thing_type="water well", + release_status="draft", + well_depth=10, + hole_depth=10, + well_casing_diameter=5.0, + well_casing_depth=10.0, + ) + other_thing = Thing( + name="Transducer Other Well", + first_visit_date="2023-03-04", + thing_type="water well", + release_status="draft", + well_depth=10, + hole_depth=10, + well_casing_diameter=5.0, + well_casing_depth=10.0, + ) + session.add_all([target_thing, other_thing]) + session.flush() + + session.add_all( + [ + LocationThingAssociation( + location_id=location.id, + thing_id=target_thing.id, + effective_start="2025-02-01T00:00:00Z", + ), + LocationThingAssociation( + location_id=second_location.id, + thing_id=other_thing.id, + effective_start="2025-02-01T00:00:00Z", + ), + ] + ) + + other_sensor = Sensor( + name=f"Transducer Other Sensor {uuid.uuid4()}", + sensor_type="Pressure Transducer", + model="Model X", + serial_no=f"serial-{uuid.uuid4()}", + pcn_number=f"pcn-{uuid.uuid4()}", + owner_agency="NMBGMR", + sensor_status="In Service", + notes="other sensor", + release_status="draft", + ) + session.add(other_sensor) + session.flush() + + target_deployment = Deployment( + sensor_id=sensor.id, + thing_id=target_thing.id, + installation_date="2023-01-01", + recording_interval=24, + recording_interval_units="hour", + hanging_cable_length=10, + hanging_point_height=0, + hanging_point_description="target deployment", + notes="target deployment", + ) + other_deployment = Deployment( + sensor_id=other_sensor.id, + thing_id=other_thing.id, + installation_date="2023-01-01", + recording_interval=24, + recording_interval_units="hour", + hanging_cable_length=10, + hanging_point_height=0, + hanging_point_description="other deployment", + notes="other deployment", + ) + session.add_all([target_deployment, other_deployment]) + session.flush() + + target_block = TransducerObservationBlock( + thing_id=target_thing.id, + parameter_id=_groundwater_level_parameter_id(), + start_datetime=observation_time - timedelta(days=10), + end_datetime=observation_time + timedelta(days=10), + review_status="not reviewed", + ) + other_block = TransducerObservationBlock( + thing_id=other_thing.id, + parameter_id=_groundwater_level_parameter_id(), + start_datetime=observation_time - timedelta(days=1), + end_datetime=observation_time + timedelta(days=1), + review_status="not reviewed", + ) + session.add_all([target_block, other_block]) + session.flush() + + observation = TransducerObservation( + parameter_id=_groundwater_level_parameter_id(), + deployment_id=target_deployment.id, + observation_datetime=observation_time, + value=12.34, + ) + session.add(observation) + session.commit() + + matching_block_id = target_block.id + observation_id = observation.id + other_block_id = other_block.id + target_deployment_id = target_deployment.id + other_deployment_id = other_deployment.id + other_sensor_id = other_sensor.id + target_thing_id = target_thing.id + other_thing_id = other_thing.id + + response = client.get( + f"/observation/transducer-groundwater-level?thing_id={target_thing_id}" + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["block"]["id"] == matching_block_id + assert data["items"][0]["block"]["id"] != other_block_id + finally: + with session_ctx() as session: + for model, pk in ( + (TransducerObservation, observation_id), + (TransducerObservationBlock, matching_block_id), + (TransducerObservationBlock, other_block_id), + (Deployment, target_deployment_id), + (Deployment, other_deployment_id), + (Sensor, other_sensor_id), + (Thing, target_thing_id), + (Thing, other_thing_id), + ): + if pk is None: + continue + instance = session.get(model, pk) + if instance is not None: + session.delete(instance) + session.commit() + + def test_get_groundwater_level_observation_by_id(groundwater_level_observation): response = client.get( f"/observation/groundwater-level/{groundwater_level_observation.id}" diff --git a/tests/test_ogc.py b/tests/test_ogc.py index e243c90b..42a0c984 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -13,10 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from datetime import datetime +from datetime import date, datetime from importlib.util import find_spec import pytest +from fastapi.testclient import TestClient from sqlalchemy import text from core.dependencies import ( @@ -27,10 +28,17 @@ viewer_function, amp_viewer_function, ) -from db import NMA_Chemistry_SampleInfo, NMA_MajorChemistry +from core.factory import create_api_app +from db import ( + Group, + GroupThingAssociation, + NMA_Chemistry_SampleInfo, + NMA_MajorChemistry, + NMA_MinorTraceChemistry, + StatusHistory, +) from db.engine import session_ctx -from main import app -from tests import client, override_authentication +from tests import override_authentication pytestmark = pytest.mark.skipif( find_spec("pygeoapi") is None, @@ -39,7 +47,8 @@ @pytest.fixture(scope="module", autouse=True) -def override_authentication_dependency_fixture(): +def ogc_client(): + app = create_api_app() app.dependency_overrides[admin_function] = override_authentication( default={"name": "foobar", "sub": "1234567890"} ) @@ -55,29 +64,30 @@ def override_authentication_dependency_fixture(): ) app.dependency_overrides[amp_viewer_function] = override_authentication() - yield + with TestClient(app) as client: + yield client app.dependency_overrides = {} -def test_ogc_landing(): - response = client.get("/ogcapi") +def test_ogc_landing(ogc_client): + response = ogc_client.get("/ogcapi") assert response.status_code == 200 payload = response.json() assert payload["title"] assert any(link["rel"] == "self" for link in payload["links"]) -def test_ogc_conformance(): - response = client.get("/ogcapi/conformance") +def test_ogc_conformance(ogc_client): + response = ogc_client.get("/ogcapi/conformance") assert response.status_code == 200 payload = response.json() assert "conformsTo" in payload assert any("ogcapi-features" in item for item in payload["conformsTo"]) -def test_ogc_openapi_has_paths(): - response = client.get("/ogcapi/openapi?f=json") +def test_ogc_openapi_has_paths(ogc_client): + response = ogc_client.get("/ogcapi/openapi?f=json") assert response.status_code == 200 payload = response.json() assert payload["openapi"].startswith("3.") @@ -190,8 +200,311 @@ def test_latest_tds_uses_latest_timestamp_within_same_day(water_well_thing): session.commit() -def test_ogc_collections(): - response = client.get("/ogcapi/collections") +def test_ogc_major_chemistry_results_uses_latest_per_analyte(water_well_thing): + with session_ctx() as session: + csi = NMA_Chemistry_SampleInfo( + thing_id=water_well_thing.id, + nma_sample_point_id="MAJNORM01", + collection_date=datetime(2024, 3, 1, 10, 0, 0), + ) + session.add(csi) + session.flush() + + # Older calcium result + calcium_old = NMA_MajorChemistry( + chemistry_sample_info_id=csi.id, + analyte="Ca", + symbol="", + sample_value=80.0, + units="mg/L", + analysis_date=datetime(2024, 3, 1, 9, 0, 0), + ) + # Newer calcium result that should win for calcium + calcium_units + calcium_new = NMA_MajorChemistry( + chemistry_sample_info_id=csi.id, + analyte="Ca", + symbol="", + sample_value=95.0, + units="mg/L as CaCO3", + analysis_date=datetime(2024, 3, 2, 9, 0, 0), + ) + # Separate analyte with even later date to drive latest_chemistry_date + chloride = NMA_MajorChemistry( + chemistry_sample_info_id=csi.id, + analyte="Cl", + symbol="", + sample_value=40.0, + units="mg/L", + analysis_date=datetime(2024, 3, 3, 8, 0, 0), + ) + + session.add_all([calcium_old, calcium_new, chloride]) + session.commit() + + session.execute(text("REFRESH MATERIALIZED VIEW ogc_major_chemistry_results")) + session.commit() + + row = session.execute( + text( + "SELECT calcium, calcium_units, chloride, chloride_units, latest_chemistry_date " + "FROM ogc_major_chemistry_results WHERE id = :thing_id" + ), + {"thing_id": water_well_thing.id}, + ).one() + + assert float(row.calcium) == 95.0 + assert row.calcium_units == "mg/L as CaCO3" + assert float(row.chloride) == 40.0 + assert row.chloride_units == "mg/L" + assert row.latest_chemistry_date.isoformat() == "2024-03-03" + + session.delete(chloride) + session.delete(calcium_new) + session.delete(calcium_old) + session.delete(csi) + session.commit() + session.execute(text("REFRESH MATERIALIZED VIEW ogc_major_chemistry_results")) + session.commit() + + +def test_ogc_minor_chemistry_wells_uses_latest_per_analyte(water_well_thing): + with session_ctx() as session: + csi = NMA_Chemistry_SampleInfo( + thing_id=water_well_thing.id, + nma_sample_point_id="MINRNORM1", + collection_date=datetime(2024, 4, 1, 10, 0, 0), + ) + session.add(csi) + session.flush() + + # Older barium result + barium_old = NMA_MinorTraceChemistry( + chemistry_sample_info_id=csi.id, + nma_sample_point_id="MINRNORM1", + analyte="Ba", + symbol="", + sample_value=0.40, + units="mg/L", + analysis_date=date(2024, 4, 1), + ) + # Newer barium result that should win for barium + barium_units + barium_new = NMA_MinorTraceChemistry( + chemistry_sample_info_id=csi.id, + nma_sample_point_id="MINRNORM1", + analyte="Ba", + symbol="", + sample_value=0.55, + units="ug/L", + analysis_date=date(2024, 4, 2), + ) + # Separate analyte with even later date to drive latest_chemistry_date + fluoride = NMA_MinorTraceChemistry( + chemistry_sample_info_id=csi.id, + nma_sample_point_id="MINRNORM1", + analyte="F", + symbol="", + sample_value=1.2, + units="mg/L", + analysis_date=date(2024, 4, 3), + ) + + session.add_all([barium_old, barium_new, fluoride]) + session.commit() + + session.execute(text("REFRESH MATERIALIZED VIEW ogc_minor_chemistry_wells")) + session.commit() + + row = session.execute( + text( + "SELECT barium, barium_units, fluoride, fluoride_units, latest_chemistry_date " + "FROM ogc_minor_chemistry_wells WHERE id = :thing_id" + ), + {"thing_id": water_well_thing.id}, + ).one() + + assert float(row.barium) == 0.55 + assert row.barium_units == "ug/L" + assert float(row.fluoride) == 1.2 + assert row.fluoride_units == "mg/L" + assert row.latest_chemistry_date.isoformat() == "2024-04-03" + + session.delete(fluoride) + session.delete(barium_new) + session.delete(barium_old) + session.delete(csi) + session.commit() + session.execute(text("REFRESH MATERIALIZED VIEW ogc_minor_chemistry_wells")) + session.commit() + + +def test_ogc_water_elevation_wells_computes_elevation_minus_depth_to_water( + water_well_thing, groundwater_level_observation +): + with session_ctx() as session: + session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_elevation_wells")) + session.commit() + + row = session.execute( + text( + "SELECT elevation_m, depth_to_water_below_ground_surface_ft, water_elevation_ft " + "FROM ogc_water_elevation_wells WHERE id = :thing_id" + ), + {"thing_id": water_well_thing.id}, + ).one() + + assert float(row.depth_to_water_below_ground_surface_ft) == 5.0 + assert float(row.elevation_m) == 2464.9 + expected_water_elevation_ft = (2464.9 * 3.28084) - 5.0 + assert abs(float(row.water_elevation_ft) - expected_water_elevation_ft) < 1e-9 + + +def test_ogc_water_elevation_wells_normalizes_meter_observations_to_feet( + water_well_thing, groundwater_level_observation +): + with session_ctx() as session: + meter_observation = groundwater_level_observation.__class__( + observation_datetime=datetime(2025, 1, 2, 0, 4, 0), + sample_id=groundwater_level_observation.sample_id, + sensor_id=groundwater_level_observation.sensor_id, + parameter_id=groundwater_level_observation.parameter_id, + release_status="draft", + value=3.0, + unit="m", + measuring_point_height=2.0, + groundwater_level_reason="Water level not affected", + ) + session.add(meter_observation) + session.commit() + + session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_elevation_wells")) + session.commit() + + row = session.execute( + text( + "SELECT depth_to_water_below_ground_surface_ft, water_elevation_ft " + "FROM ogc_water_elevation_wells WHERE id = :thing_id" + ), + {"thing_id": water_well_thing.id}, + ).one() + + expected_depth_ft = (3.0 * 3.28084) - 2.0 + expected_water_elevation_ft = (2464.9 * 3.28084) - expected_depth_ft + + assert ( + abs(float(row.depth_to_water_below_ground_surface_ft) - expected_depth_ft) + < 1e-9 + ) + assert abs(float(row.water_elevation_ft) - expected_water_elevation_ft) < 1e-9 + + session.delete(meter_observation) + session.commit() + session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_elevation_wells")) + session.commit() + + +def test_ogc_actively_monitored_wells_exposes_water_level_network_group_wells( + water_well_thing, + groundwater_level_observation, +): + with session_ctx() as session: + session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_well_summary")) + session.commit() + + group = Group( + name="Water Level Network", + group_type="Monitoring Plan", + release_status="draft", + ) + session.add(group) + session.flush() + + group_assoc = GroupThingAssociation( + group_id=group.id, + thing_id=water_well_thing.id, + ) + session.add(group_assoc) + status_history = StatusHistory( + status_type="Monitoring Status", + status_value="Currently monitored", + start_date=date(2024, 1, 1), + target_id=water_well_thing.id, + target_table="thing", + ) + session.add(status_history) + session.commit() + + row = session.execute( + text( + "SELECT group_id, group_name, group_type " + "FROM ogc_actively_monitored_wells WHERE id = :thing_id" + ), + {"thing_id": water_well_thing.id}, + ).one() + + assert row.group_id == group.id + assert row.group_name == "Water Level Network" + assert row.group_type == "Monitoring Plan" + + session.delete(status_history) + session.delete(group_assoc) + session.delete(group) + session.commit() + + +def test_ogc_actively_monitored_wells_excludes_latest_not_currently_monitored( + water_well_thing, + groundwater_level_observation, +): + with session_ctx() as session: + session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_well_summary")) + session.commit() + + group = Group( + name="Water Level Network", + group_type="Monitoring Plan", + release_status="draft", + ) + session.add(group) + session.flush() + + group_assoc = GroupThingAssociation( + group_id=group.id, + thing_id=water_well_thing.id, + ) + session.add(group_assoc) + currently_monitored = StatusHistory( + status_type="Monitoring Status", + status_value="Currently monitored", + start_date=date(2024, 1, 1), + target_id=water_well_thing.id, + target_table="thing", + ) + not_currently_monitored = StatusHistory( + status_type="Monitoring Status", + status_value="Not currently monitored", + start_date=date(2024, 2, 1), + target_id=water_well_thing.id, + target_table="thing", + ) + session.add_all([currently_monitored, not_currently_monitored]) + session.commit() + + row = session.execute( + text("SELECT id FROM ogc_actively_monitored_wells WHERE id = :thing_id"), + {"thing_id": water_well_thing.id}, + ).one_or_none() + + assert row is None + + session.delete(not_currently_monitored) + session.delete(currently_monitored) + session.delete(group_assoc) + session.delete(group) + session.commit() + + +def test_ogc_collections(ogc_client): + response = ogc_client.get("/ogcapi/collections") assert response.status_code == 200 payload = response.json() ids = {collection["id"] for collection in payload["collections"]} @@ -201,41 +514,60 @@ def test_ogc_collections(): "springs", "latest_tds_wells", "depth_to_water_trend_wells", + "water_elevation_wells", "water_well_summary", + "major_chemistry_results", + "minor_chemistry_wells", + "actively_monitored_wells", + "project_areas", }.issubset(ids) -def test_ogc_new_collection_items_endpoints(): +def test_ogc_new_collection_items_endpoints(ogc_client): for collection_id in ( "latest_tds_wells", "depth_to_water_trend_wells", + "water_elevation_wells", "water_well_summary", + "major_chemistry_results", + "minor_chemistry_wells", + "actively_monitored_wells", + "project_areas", ): - response = client.get(f"/ogcapi/collections/{collection_id}/items?limit=10") + response = ogc_client.get(f"/ogcapi/collections/{collection_id}/items?limit=10") assert response.status_code == 200 payload = response.json() assert payload["type"] == "FeatureCollection" +def test_ogc_project_areas_items_expose_groups_with_project_areas(ogc_client, group): + response = ogc_client.get("/ogcapi/collections/project_areas/items?limit=20") + + assert response.status_code == 200 + payload = response.json() + ids = {str(feature["id"]) for feature in payload["features"]} + assert str(group.id) in ids + + @pytest.mark.skip("PostGIS spatial operators not available in CI - see issue #449") def test_ogc_locations_items_bbox(location): bbox = "-107.95,33.80,-107.94,33.81" - response = client.get(f"/ogcapi/collections/locations/items?bbox={bbox}") + response = ogc_client.get(f"/ogcapi/collections/locations/items?bbox={bbox}") assert response.status_code == 200 payload = response.json() assert payload["type"] == "FeatureCollection" assert payload["numberReturned"] >= 1 -def test_ogc_wells_items_and_item(water_well_thing): - response = client.get("/ogcapi/collections/water_wells/items?limit=20") +def test_ogc_wells_items_and_item(ogc_client, water_well_thing): + response = ogc_client.get("/ogcapi/collections/water_wells/items?limit=20") assert response.status_code == 200 payload = response.json() assert payload["numberReturned"] >= 1 ids = {str(feature["id"]) for feature in payload["features"]} assert str(water_well_thing.id) in ids - response = client.get( + response = ogc_client.get( f"/ogcapi/collections/water_wells/items/{water_well_thing.id}" ) assert response.status_code == 200 @@ -246,7 +578,7 @@ def test_ogc_wells_items_and_item(water_well_thing): @pytest.mark.skip("PostGIS spatial operators not available in CI - see issue #449") def test_ogc_polygon_within_filter(location): polygon = "POLYGON((-107.95 33.80,-107.94 33.80,-107.94 33.81,-107.95 33.81,-107.95 33.80))" - response = client.get( + response = ogc_client.get( "/ogcapi/collections/locations/items", params={ "filter": f"WITHIN(geometry,{polygon})", diff --git a/tests/test_pygeoapi_mount.py b/tests/test_pygeoapi_mount.py new file mode 100644 index 00000000..c789dc30 --- /dev/null +++ b/tests/test_pygeoapi_mount.py @@ -0,0 +1,50 @@ +import types + +from core import pygeoapi + + +def test_load_pygeoapi_app_imports_when_module_not_loaded(monkeypatch): + fake_module = types.SimpleNamespace(APP=object()) + import_calls = [] + + def fake_import_module(name): + import_calls.append(name) + return fake_module + + monkeypatch.delitem( + pygeoapi.sys.modules, + "pygeoapi.starlette_app", + raising=False, + ) + monkeypatch.setattr( + pygeoapi.importlib, + "import_module", + fake_import_module, + ) + + app = pygeoapi._load_pygeoapi_app() + + assert app is fake_module.APP + assert import_calls == ["pygeoapi.starlette_app"] + + +def test_load_pygeoapi_app_reloads_when_module_already_loaded(monkeypatch): + existing_module = types.SimpleNamespace(APP=object()) + reloaded_module = types.SimpleNamespace(APP=object()) + reload_calls = [] + + def fake_reload(module): + reload_calls.append(module) + return reloaded_module + + monkeypatch.setitem( + pygeoapi.sys.modules, + "pygeoapi.starlette_app", + existing_module, + ) + monkeypatch.setattr(pygeoapi.importlib, "reload", fake_reload) + + app = pygeoapi._load_pygeoapi_app() + + assert app is reloaded_module.APP + assert reload_calls == [existing_module] diff --git a/tests/test_request_timing.py b/tests/test_request_timing.py new file mode 100644 index 00000000..78e4a9f4 --- /dev/null +++ b/tests/test_request_timing.py @@ -0,0 +1,42 @@ +import logging + +from fastapi.testclient import TestClient + +from core.app import create_base_app + + +def test_request_lifecycle_logs_start_and_completion(caplog): + app = create_base_app() + + @app.get("/ping") + async def ping(): + return {"status": "ok"} + + with caplog.at_level(logging.INFO, logger="core.app"): + with TestClient(app) as client: + assert client.get("/ping").status_code == 200 + assert client.get("/ping").status_code == 200 + + startup_logs = [ + record for record in caplog.records if record.msg == "instance startup complete" + ] + request_started_logs = [ + record for record in caplog.records if record.msg == "request started" + ] + request_completed_logs = [ + record for record in caplog.records if record.msg == "request completed" + ] + assert len(startup_logs) == 1 + assert len(request_started_logs) == 2 + assert len(request_completed_logs) == 2 + + assert startup_logs[0].event == "instance_startup_complete" + assert startup_logs[0].startup_ms >= 0 + assert request_started_logs[0].event == "request_started" + assert request_started_logs[0].request_id + assert request_started_logs[0].path == "/ping" + assert request_completed_logs[0].event == "request_completed" + assert request_completed_logs[0].request_id == request_started_logs[0].request_id + assert request_completed_logs[0].status_code == 200 + assert request_completed_logs[1].request_id == request_started_logs[1].request_id + assert request_completed_logs[1].status_code == 200 diff --git a/tests/test_sample.py b/tests/test_sample.py index 341bf6a6..e8e08246 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -285,7 +285,11 @@ def test_get_samples(water_chemistry_sample, groundwater_level_sample): response = client.get("/sample") assert response.status_code == 200 data = response.json() - assert len(data["items"]) == 2 + assert len(data["items"]) >= 2 + + item_ids = {item["id"] for item in data["items"]} + assert water_chemistry_sample.id in item_ids + assert groundwater_level_sample.id in item_ids for item in data["items"]: assert "id" in item @@ -312,7 +316,7 @@ def test_get_samples_by_thing_id( response = client.get(f"/sample?thing_id={water_well_thing.id}") assert response.status_code == 200 data = response.json() - assert data["total"] == 2 + assert data["total"] >= 2 data_ids = [d["id"] for d in data["items"]] sorted_data_ids = sorted(data_ids) diff --git a/tests/test_scoped_transfer_cli.py b/tests/test_scoped_transfer_cli.py new file mode 100644 index 00000000..7011b96e --- /dev/null +++ b/tests/test_scoped_transfer_cli.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +import logging +from types import SimpleNamespace + +from sqlalchemy.exc import IntegrityError +from typer.testing import CliRunner + +from cli.cli import cli +import services.scoped_transfer as scoped_transfer_module +from services.scoped_transfer import ( + FamilySpec, + ScopedFamilyResult, + ScopedTransferOptions, + ScopedTransferResult, + ScopedTransferRuntime, + ScopedTransferLogFilter, + ScopedWaterLevelTransferer, + ScopedWellTransferer, + _plan_chemistry_child_table, + _plan_chemistry_sampleinfo, + _plan_groups, + normalize_pointids, + run_scoped_transfer, +) + + +class _FakeSavepoint: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class _FakeScalarQuery: + def __init__(self, session): + self.session = session + + def filter(self, *_args, **_kwargs): + return self + + def scalar(self): + return self.session.scalar_results.pop(0) + + +class _FakeParticipantSession: + def __init__(self, scalar_results): + self.begin_nested_calls = 0 + self.scalar_results = list(scalar_results) + self.execute_calls = [] + + def begin_nested(self): + self.begin_nested_calls += 1 + return _FakeSavepoint() + + def execute(self, statement, params): + self.execute_calls.append((statement, params)) + raise IntegrityError("insert", {}, Exception("duplicate key")) + + def query(self, *_args, **_kwargs): + return _FakeScalarQuery(self) + + +def test_scoped_transfer_cli_json_output(monkeypatch): + def fake_run(_options): + return ScopedTransferResult( + pointids=["SM-0001"], + selected_families=["wells"], + added_prerequisites=[], + dry_run=True, + family_results=[ + ScopedFamilyResult( + family="wells", + status="planned", + applicable_source_rows=1, + ) + ], + validation_errors=[], + exit_code=0, + ) + + monkeypatch.setattr("services.scoped_transfer.run_scoped_transfer", fake_run) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "scoped-transfer", + "--pointid", + "SM-0001", + "--dry-run", + "--output", + "json", + ], + ) + + assert result.exit_code == 0, result.output + assert '"pointids": [' in result.output + assert '"selected_families": [' in result.output + + +def test_scoped_transfer_cli_human_output(monkeypatch): + def fake_run(_options): + return ScopedTransferResult( + pointids=["SM-0001"], + selected_families=["wells", "contacts"], + added_prerequisites=["contacts"], + dry_run=False, + family_results=[ + ScopedFamilyResult( + family="wells", + status="completed", + applicable_source_rows=1, + created=1, + ), + ScopedFamilyResult( + family="contacts", + status="completed", + applicable_source_rows=1, + created=1, + added_as_prerequisite=True, + ), + ], + validation_errors=[], + exit_code=0, + ) + + monkeypatch.setattr("services.scoped_transfer.run_scoped_transfer", fake_run) + + runner = CliRunner() + result = runner.invoke(cli, ["scoped-transfer", "--pointid", "SM-0001"]) + + assert result.exit_code == 0, result.output + assert "Starting scoped transfer for PointIDs: SM-0001" in result.output + assert "Validating requested scope and preparing execution..." in result.output + assert "[SCOPED TRANSFER]" in result.output + assert "Requested PointIDs: SM-0001" in result.output + assert "Auto-added prerequisites: contacts" in result.output + assert "wells" in result.output + assert "contacts" in result.output + + +def test_normalize_pointids_dedupes_and_uppercases(): + assert normalize_pointids([" sm-0001 ", "SM-0001", "sp-1"]) == [ + "SM-0001", + "SP-1", + ] + + +def test_scoped_transfer_runtime_expands_dependencies(): + runtime = ScopedTransferRuntime( + ScopedTransferOptions(pointids=["SM-0001"], only=["field-parameters"]) + ) + + assert runtime.selected_family_names == [ + "wells", + "chemistry-sampleinfo", + "field-parameters", + ] + assert runtime.added_prerequisites == ["chemistry-sampleinfo", "wells"] + + +def test_run_scoped_transfer_fails_preflight_when_pointid_missing(monkeypatch): + def fake_registry(): + return { + "wells": FamilySpec( + name="wells", + planner=lambda _runtime: ScopedFamilyResult( + family="wells", + status="no-op", + applicable_source_rows=0, + ), + executor=lambda _runtime: ScopedFamilyResult( + family="wells", + status="no-op", + applicable_source_rows=0, + ), + ) + } + + monkeypatch.setattr("services.scoped_transfer.build_family_registry", fake_registry) + + result = run_scoped_transfer( + ScopedTransferOptions(pointids=["DOES-NOT-EXIST"], only=["wells"], dry_run=True) + ) + + assert result.exit_code == 1 + assert result.validation_errors + assert "DOES-NOT-EXIST" in result.validation_errors[0] + + +def test_run_scoped_transfer_dry_run_returns_planned_results(monkeypatch): + def fake_registry(): + return { + "wells": FamilySpec( + name="wells", + planner=lambda _runtime: ScopedFamilyResult( + family="wells", + status="planned", + applicable_source_rows=1, + ), + executor=lambda _runtime: ScopedFamilyResult( + family="wells", + status="completed", + applicable_source_rows=1, + ), + ) + } + + monkeypatch.setattr("services.scoped_transfer.build_family_registry", fake_registry) + monkeypatch.setattr( + "services.scoped_transfer.read_csv", + lambda *args, **kwargs: __import__("pandas").DataFrame( + {"PointID": ["SM-0001"]} + ), + ) + monkeypatch.setattr( + "services.scoped_transfer.replace_nans", + lambda df: df, + ) + + result = run_scoped_transfer( + ScopedTransferOptions(pointids=["SM-0001"], only=["wells"], dry_run=True) + ) + + assert result.exit_code == 0 + assert result.dry_run is True + assert len(result.family_results) == 1 + assert result.family_results[0].status == "planned" + + +def test_plan_chemistry_sampleinfo_uses_samplepointid_prefix(monkeypatch): + import pandas as pd + + monkeypatch.setattr( + "services.scoped_transfer.read_csv", + lambda name, *args, **kwargs: ( + pd.DataFrame( + { + "SamplePointID": ["SM-0001A", "SM-0001B", "SM-9999A"], + "SamplePtID": ["a", "b", "c"], + } + ) + if name == "Chemistry_SampleInfo" + else pd.DataFrame() + ), + ) + + result = _plan_chemistry_sampleinfo(["SM-0001"]) + + assert result.status == "planned" + assert result.applicable_source_rows == 2 + + +def test_plan_chemistry_child_table_uses_sample_pt_ids_from_sampleinfo(monkeypatch): + import pandas as pd + + def fake_read_csv(name, *args, **kwargs): + if name == "Chemistry_SampleInfo": + return pd.DataFrame( + { + "SamplePointID": ["SM-0001A", "SM-0001B", "ZZ-0001A"], + "SamplePtID": ["A", "B", "Z"], + } + ) + if name == "MajorChemistry": + return pd.DataFrame( + { + "SamplePtID": ["A", "A", "B", "Z"], + } + ) + return pd.DataFrame() + + monkeypatch.setattr("services.scoped_transfer.read_csv", fake_read_csv) + + result = _plan_chemistry_child_table("MajorChemistry", ["SM-0001"]) + + assert result.status == "planned" + assert result.applicable_source_rows == 3 + + +def test_plan_groups_counts_matching_prefixes_only(monkeypatch): + import pandas as pd + + monkeypatch.setattr( + "services.scoped_transfer.read_csv", + lambda *args, **kwargs: pd.DataFrame( + { + "Project": ["Sacramento", "Questa", "Other"], + "PointIDPrefix": ["SM, SO", "QU", "AB"], + } + ), + ) + + result = _plan_groups(["SM-0001"]) + + assert result.status == "planned" + assert result.applicable_source_rows == 1 + + +def test_scoped_waterlevels_reuses_existing_contacts_after_insert_collision( + monkeypatch, +): + monkeypatch.setattr( + scoped_transfer_module, + "get_contacts_info", + lambda row, measured_by, mapper: [ + ("Alice Example", "NMBGMR", "Technician"), + ], + ) + + transferer = ScopedWaterLevelTransferer.__new__(ScopedWaterLevelTransferer) + transferer._created_contact_id_by_key = {} + transferer._owner_contact_id_by_pointid = {} + transferer._measured_by_mapper = {} + transferer._last_contacts_created_count = 0 + transferer._last_contacts_reused_count = 0 + + session = _FakeParticipantSession(scalar_results=[42]) + row = SimpleNamespace( + PointID="SM-0001", + GlobalID="gid-1", + MeasuredBy="NMBGMR_TECH", + ) + + participant_ids = transferer._get_field_event_participant_ids(session, row) + + assert participant_ids == [42] + assert session.begin_nested_calls == 1 + assert len(session.execute_calls) == 1 + assert transferer._created_contact_id_by_key == { + ("Alice Example", "NMBGMR"): 42, + } + assert transferer._last_contacts_created_count == 0 + assert transferer._last_contacts_reused_count == 1 + + +def test_scoped_wells_duplicate_check_only_applies_to_requested_pointids(monkeypatch): + import pandas as pd + + well_df = pd.DataFrame( + { + "PointID": ["SM-0001", "QU-047", "QU-047", "DA-0047", "DA-0047"], + "LocationId": [1, 2, 3, 4, 5], + "SiteType": ["GW"] * 5, + "Easting": [1] * 5, + "Northing": [1] * 5, + "OSEWelltagID": [None] * 5, + } + ) + location_df = pd.DataFrame( + { + "LocationId": [1, 2, 3, 4, 5], + "PointID": ["SM-0001", "QU-047", "QU-047", "DA-0047", "DA-0047"], + "SSMA_TimeStamp": [None] * 5, + } + ) + + def fake_read_csv(name, *args, **kwargs): + if name == "WellData": + return well_df.copy() + if name == "Location": + return location_df.copy() + raise AssertionError(f"Unexpected table {name}") + + monkeypatch.setattr(scoped_transfer_module, "read_csv", fake_read_csv) + monkeypatch.setattr(scoped_transfer_module, "replace_nans", lambda df: df) + monkeypatch.setattr( + scoped_transfer_module, + "get_transferable_wells", + lambda df: df, + ) + monkeypatch.setattr( + scoped_transfer_module, + "filter_non_transferred_wells", + lambda df: df, + ) + + transferer = ScopedWellTransferer.__new__(ScopedWellTransferer) + transferer.pointids = ["SM-0001"] + + _input_df, cleaned_df = transferer._get_dfs() + + assert cleaned_df["PointID"].tolist() == ["SM-0001"] + + +def test_scoped_transfer_log_filter_suppresses_known_noise_patterns(): + log_filter = ScopedTransferLogFilter() + + suppressed = logging.LogRecord( + name="test", + level=logging.WARNING, + pathname=__file__, + lineno=1, + msg=( + "Filtered out 288 HydraulicsData records without matching Things " + "(0 valid, 288 orphan records prevented)" + ), + args=(), + exc_info=None, + ) + allowed = logging.LogRecord( + name="test", + level=logging.WARNING, + pathname=__file__, + lineno=1, + msg="Actual scoped warning that should remain visible", + args=(), + exc_info=None, + ) + + assert log_filter.filter(suppressed) is False + assert log_filter.filter(allowed) is True diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 56bfdc9a..eabaf7e5 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -175,20 +175,21 @@ def test_get_sensors(sensor): response = client.get("/sensor") assert response.status_code == 200 data = response.json() - assert data["total"] == 1 - assert data["items"][0]["id"] == sensor.id - assert data["items"][0]["created_at"] == sensor.created_at.astimezone( - timezone.utc - ).strftime(DT_FMT) - assert data["items"][0]["release_status"] == sensor.release_status - assert data["items"][0]["name"] == sensor.name - assert data["items"][0]["sensor_type"] == sensor.sensor_type - assert data["items"][0]["model"] == sensor.model - assert data["items"][0]["serial_no"] == sensor.serial_no - assert data["items"][0]["pcn_number"] == sensor.pcn_number - assert data["items"][0]["owner_agency"] == sensor.owner_agency - assert data["items"][0]["sensor_status"] == sensor.sensor_status - assert data["items"][0]["notes"] == sensor.notes + assert data["total"] >= 1 + item = next((item for item in data["items"] if item["id"] == sensor.id), None) + assert item is not None + assert item["created_at"] == sensor.created_at.astimezone(timezone.utc).strftime( + DT_FMT + ) + assert item["release_status"] == sensor.release_status + assert item["name"] == sensor.name + assert item["sensor_type"] == sensor.sensor_type + assert item["model"] == sensor.model + assert item["serial_no"] == sensor.serial_no + assert item["pcn_number"] == sensor.pcn_number + assert item["owner_agency"] == sensor.owner_agency + assert item["sensor_status"] == sensor.sensor_status + assert item["notes"] == sensor.notes def test_get_sensors_by_thing_id( @@ -219,20 +220,21 @@ def test_get_sensors_by_parameter_id(sensor, groundwater_level_observation): response = client.get(f"/sensor?parameter_id={_groundwater_level_parameter_id()}") assert response.status_code == 200 data = response.json() - assert data["total"] == 1 - assert data["items"][0]["id"] == sensor.id - assert data["items"][0]["created_at"] == sensor.created_at.astimezone( - timezone.utc - ).strftime(DT_FMT) - assert data["items"][0]["release_status"] == sensor.release_status - assert data["items"][0]["name"] == sensor.name - assert data["items"][0]["sensor_type"] == sensor.sensor_type - assert data["items"][0]["model"] == sensor.model - assert data["items"][0]["serial_no"] == sensor.serial_no - assert data["items"][0]["pcn_number"] == sensor.pcn_number - assert data["items"][0]["owner_agency"] == sensor.owner_agency - assert data["items"][0]["sensor_status"] == sensor.sensor_status - assert data["items"][0]["notes"] == sensor.notes + assert data["total"] >= 1 + item = next((item for item in data["items"] if item["id"] == sensor.id), None) + assert item is not None + assert item["created_at"] == sensor.created_at.astimezone(timezone.utc).strftime( + DT_FMT + ) + assert item["release_status"] == sensor.release_status + assert item["name"] == sensor.name + assert item["sensor_type"] == sensor.sensor_type + assert item["model"] == sensor.model + assert item["serial_no"] == sensor.serial_no + assert item["pcn_number"] == sensor.pcn_number + assert item["owner_agency"] == sensor.owner_agency + assert item["sensor_status"] == sensor.sensor_status + assert item["notes"] == sensor.notes def test_get_sensor_by_id(sensor): diff --git a/tests/test_thing.py b/tests/test_thing.py index 6cba4800..2dde25fb 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -16,6 +16,7 @@ from datetime import date, timezone import pytest +from sqlalchemy import delete from core.dependencies import ( admin_function, @@ -120,9 +121,12 @@ def test_measuring_point_properties_skip_null_history(): assert well.measuring_point_height == 2.5 assert well.measuring_point_description == "old mp" - session.delete(new_history) - session.delete(old_history) - session.delete(well) + session.execute( + delete(MeasuringPointHistory).where( + MeasuringPointHistory.thing_id == well.id + ) + ) + session.execute(delete(Thing).where(Thing.id == well.id)) session.commit() @@ -556,6 +560,153 @@ def test_get_water_well_by_id(water_well_thing, location): assert data["current_location"] == expected_location +def test_get_water_well_details_payload( + water_well_thing, + field_event, + contact, + email, + phone, + address, + sensor, + sensor_to_water_well_thing_deployment, + thing_id_link, + well_screen, + groundwater_level_sample, + groundwater_level_observation, +): + response = client.get(f"/thing/water-well/{water_well_thing.id}/details") + + assert response.status_code == 200 + data = response.json() + + assert data["well"]["id"] == water_well_thing.id + assert data["well"]["alternate_ids"][0]["id"] == thing_id_link.id + assert data["contacts"][0]["id"] == contact.id + assert data["contacts"][0]["emails"][0]["id"] == email.id + assert data["contacts"][0]["phones"][0]["id"] == phone.id + assert data["contacts"][0]["addresses"][0]["id"] == address.id + assert data["sensors"][0]["id"] == sensor.id + assert data["deployments"][0]["id"] == sensor_to_water_well_thing_deployment.id + assert data["deployments"][0]["sensor"]["id"] == sensor.id + assert data["well_screens"][0]["id"] == well_screen.id + assert ( + data["recent_groundwater_level_observations"][0]["id"] + == groundwater_level_observation.id + ) + assert data["latest_field_event_sample"]["id"] == groundwater_level_sample.id + assert data["latest_field_event_sample"]["field_event"]["id"] == field_event.id + assert data["latest_field_event_sample"]["contact"]["id"] == contact.id + + +def test_get_water_well_details_payload_uses_latest_observation_sample( + water_well_thing, + groundwater_level_sample, + groundwater_level_observation, + field_event_participant, + sensor, +): + from db import Observation, Sample + + with session_ctx() as session: + later_sample = Sample( + field_activity_id=groundwater_level_sample.field_activity_id, + field_event_participant_id=field_event_participant.id, + sample_date="2025-01-02T12:00:00Z", + sample_name="later groundwater level sample", + sample_matrix="water", + sample_method="Steel-tape measurement", + qc_type="Normal", + notes="later sample", + release_status="draft", + ) + session.add(later_sample) + session.commit() + session.refresh(later_sample) + + later_observation = Observation( + observation_datetime="2025-01-02T00:04:00Z", + sample_id=later_sample.id, + sensor_id=sensor.id, + parameter_id=groundwater_level_observation.parameter_id, + release_status="draft", + value=9.0, + unit="ft", + measuring_point_height=5.0, + groundwater_level_reason="Water level not affected", + ) + session.add(later_observation) + session.commit() + session.refresh(later_observation) + later_sample_id = later_sample.id + later_observation_id = later_observation.id + + try: + response = client.get(f"/thing/water-well/{water_well_thing.id}/details") + + assert response.status_code == 200 + data = response.json() + assert data["latest_field_event_sample"]["id"] == later_sample_id + assert ( + data["recent_groundwater_level_observations"][0]["id"] + == later_observation_id + ) + finally: + with session_ctx() as session: + later_observation = session.get(Observation, later_observation_id) + if later_observation is not None: + session.delete(later_observation) + later_sample = session.get(Sample, later_sample_id) + if later_sample is not None: + session.delete(later_sample) + session.commit() + + +def test_get_water_well_details_payload_404_not_found(): + response = client.get("/thing/water-well/999999/details") + + assert response.status_code == 404 + assert response.json()["detail"] == "Thing with ID 999999 not found." + + +def test_get_water_well_by_id_includes_location_properties( + water_well_thing, +): + response = client.get(f"/thing/water-well/{water_well_thing.id}") + + assert response.status_code == 200 + data = response.json() + + assert data["current_location"]["properties"]["county"] == "Sierra" + assert data["current_location"]["properties"]["state"] == "NM" + assert data["current_location"]["properties"]["quad_name"] == "Hillsboro Peak" + + +def test_get_water_wells_includes_contact_summary( + water_well_thing, + contact, +): + response = client.get("/thing/water-well") + + assert response.status_code == 200 + data = response.json() + + well = next( + (item for item in data["items"] if item["id"] == water_well_thing.id), None + ) + assert well is not None, f"Well {water_well_thing.id} not found in response" + assert well["contacts"] == [ + { + "id": contact.id, + "created_at": contact.created_at.astimezone(timezone.utc).strftime(DT_FMT), + "release_status": contact.release_status, + "name": contact.name, + "organization": contact.organization, + "contact_type": contact.contact_type, + "role": contact.role, + } + ] + + def test_get_water_well_by_id_404_not_found(water_well_thing): bad_id = 99999 response = client.get(f"/thing/water-well/{bad_id}") @@ -727,19 +878,19 @@ def test_get_thing_id_links(thing_id_link): response = client.get("/thing/id-link") assert response.status_code == 200 data = response.json() - assert data["total"] == 1 - assert data["items"][0]["id"] == thing_id_link.id - assert data["items"][0]["created_at"] == thing_id_link.created_at.astimezone( + assert data["total"] >= 1 + item = next( + (item for item in data["items"] if item["id"] == thing_id_link.id), None + ) + assert item is not None + assert item["created_at"] == thing_id_link.created_at.astimezone( timezone.utc ).strftime(DT_FMT) - assert data["items"][0]["release_status"] == thing_id_link.release_status - assert data["items"][0]["thing_id"] == thing_id_link.thing_id - assert data["items"][0]["relation"] == thing_id_link.relation - assert data["items"][0]["alternate_id"] == thing_id_link.alternate_id - assert ( - data["items"][0]["alternate_organization"] - == thing_id_link.alternate_organization - ) + assert item["release_status"] == thing_id_link.release_status + assert item["thing_id"] == thing_id_link.thing_id + assert item["relation"] == thing_id_link.relation + assert item["alternate_id"] == thing_id_link.alternate_id + assert item["alternate_organization"] == thing_id_link.alternate_organization def test_get_thing_id_link_by_id(thing_id_link): @@ -793,11 +944,11 @@ def test_get_things(water_well_thing, spring_thing, location): response = client.get("/thing") assert response.status_code == 200 - expected_location = LocationResponse.model_validate(location).model_dump() - # created_at is already serialized to UTC format by UTCAwareDatetime - data = response.json() - assert data["total"] == 2 + assert data["total"] >= 2 + item_ids = {item["id"] for item in data["items"]} + assert water_well_thing.id in item_ids + assert spring_thing.id in item_ids @pytest.mark.skip("Needs to be updated per changes made from feature files") diff --git a/tests/test_water_level_csv_schema.py b/tests/test_water_level_csv_schema.py new file mode 100644 index 00000000..09abed2d --- /dev/null +++ b/tests/test_water_level_csv_schema.py @@ -0,0 +1,179 @@ +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from schemas.water_level_csv import WaterLevelCsvRow + +DATA_QUALITY_VALUE = "Water level accurate to within two hundreths of a foot" + + +def test_water_level_csv_row_normalizes_source_headers_and_naive_datetimes(): + row = WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + field_staff_2="", + field_staff_3="", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 1", + sample_method="electric tape", + mp_height="1.5", + level_status="Water level not affected", + depth_to_water_ft="45.2", + data_quality=DATA_QUALITY_VALUE, + water_level_notes="Initial measurement", + ) + + assert row.field_staff_2 is None + assert row.field_staff_3 is None + assert row.sample_method == "Electric tape measurement (E-probe)" + assert row.field_event_date_time == datetime( + 2025, 2, 15, 15, 0, tzinfo=timezone.utc + ) + assert row.water_level_date_time == datetime( + 2025, 2, 15, 17, 30, tzinfo=timezone.utc + ) + + +def test_water_level_csv_row_accepts_legacy_alias_headers(): + row = WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00-07:00", + field_staff="Tech 1", + measurement_date_time="2025-02-15T10:30:00-07:00", + sampler="Tech 1", + sample_method="Steel-tape measurement", + mp_height_ft="2.5", + depth_to_water_ft="45.2", + ) + + assert row.measuring_person == "Tech 1" + assert row.sampler == "Tech 1" + assert row.mp_height == 2.5 + assert row.measurement_date_time == datetime( + 2025, 2, 15, 17, 30, tzinfo=timezone.utc + ) + + +def test_water_level_csv_row_normalizes_blank_optional_values_to_none(): + row = WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 1", + sample_method="Steel-tape measurement", + mp_height="", + level_status="Water level not affected", + depth_to_water_ft="", + data_quality="", + water_level_notes="", + ) + + assert row.mp_height is None + assert row.level_status == "Water level not affected" + assert row.depth_to_water_ft is None + assert row.data_quality is None + assert row.water_level_notes is None + + +def test_water_level_csv_row_requires_measuring_person_to_match_field_staff(): + with pytest.raises(ValidationError) as exc: + WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + field_staff_2="Tech 2", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 3", + sample_method="Steel-tape measurement", + depth_to_water_ft="45.2", + ) + + assert ( + "measuring_person must match one of field_staff, field_staff_2, " + "or field_staff_3" + ) in str(exc.value) + + +def test_water_level_csv_row_requires_level_status_when_depth_is_blank(): + with pytest.raises(ValidationError) as exc: + WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 1", + sample_method="Steel-tape measurement", + depth_to_water_ft="", + level_status="", + ) + + assert "level_status is required when depth_to_water_ft is blank" in str(exc.value) + + +def test_water_level_csv_row_rejects_water_level_before_field_event(): + with pytest.raises(ValidationError) as exc: + WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T10:30:00", + field_staff="Tech 1", + water_level_date_time="2025-02-15T08:00:00", + measuring_person="Tech 1", + sample_method="Steel-tape measurement", + depth_to_water_ft="45.2", + ) + + assert ( + "water_level_date_time must be greater than or equal to " + "field_event_date_time" + ) in str(exc.value) + + +def test_water_level_csv_row_canonicalizes_case_insensitive_lexicon_values(): + row = WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 1", + sample_method="electric tape measurement (e-probe)", + depth_to_water_ft="", + level_status="dry", + data_quality=DATA_QUALITY_VALUE.lower(), + ) + + assert row.sample_method == "Electric tape measurement (E-probe)" + assert row.level_status == "Site was dry" + assert row.data_quality == DATA_QUALITY_VALUE + + +def test_water_level_csv_row_allows_negative_mp_height(): + row = WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 1", + sample_method="Steel-tape measurement", + mp_height="-0.1", + depth_to_water_ft="45.2", + ) + + assert row.mp_height == -0.1 + + +def test_water_level_csv_row_rejects_negative_depth_to_water(): + with pytest.raises(ValidationError) as exc: + WaterLevelCsvRow( + well_name_point_id="AR0001", + field_event_date_time="2025-02-15T08:00:00", + field_staff="Tech 1", + water_level_date_time="2025-02-15T10:30:00", + measuring_person="Tech 1", + sample_method="Steel-tape measurement", + depth_to_water_ft="-0.1", + ) + + assert "depth_to_water_ft must be greater than or equal to 0" in str(exc.value) diff --git a/tests/test_water_level_csv_service.py b/tests/test_water_level_csv_service.py new file mode 100644 index 00000000..e4b01d9c --- /dev/null +++ b/tests/test_water_level_csv_service.py @@ -0,0 +1,527 @@ +from datetime import date, datetime, timezone +from decimal import Decimal +from types import SimpleNamespace + +from db import FieldActivity, FieldEvent, Observation, Sample, Thing +from db.measuring_point_history import MeasuringPointHistory +from db.engine import session_ctx +from tests import get_parameter_id +from services.water_level_csv import ( + _build_sample_name, + _create_records, + _resolve_measuring_point_height, + _validate_depth_to_water_against_well, + bulk_upload_water_levels, +) +from sqlalchemy import select + + +def _build_well( + *, + well_depth: float | None = None, + measuring_point_height: float | None = None, +) -> Thing: + well = Thing(name="AR0001", thing_type="water well", well_depth=well_depth) + well.measuring_points = [] + if measuring_point_height is not None: + well.measuring_points.append( + MeasuringPointHistory( + start_date=date(2025, 1, 1), + measuring_point_height=measuring_point_height, + ) + ) + return well + + +def test_resolve_measuring_point_height_prefers_csv_value(): + well = _build_well(measuring_point_height=3.5) + + ( + resolved_mp_height, + existing_mp_height, + differs, + ) = _resolve_measuring_point_height(well, 4.0) + + assert resolved_mp_height == 4.0 + assert existing_mp_height == 3.5 + assert differs is True + + +def test_resolve_measuring_point_height_falls_back_to_well_history(): + well = _build_well(measuring_point_height=3.5) + + ( + resolved_mp_height, + existing_mp_height, + differs, + ) = _resolve_measuring_point_height(well, None) + + assert resolved_mp_height == 3.5 + assert existing_mp_height == 3.5 + assert differs is False + + +def test_resolve_measuring_point_height_coerces_decimal_history_value(): + well = _build_well(measuring_point_height=Decimal("3.5")) + + ( + resolved_mp_height, + existing_mp_height, + differs, + ) = _resolve_measuring_point_height(well, None) + + assert resolved_mp_height == 3.5 + assert existing_mp_height == 3.5 + assert differs is False + + +def test_resolve_measuring_point_height_allows_missing_values(): + well = _build_well() + + ( + resolved_mp_height, + existing_mp_height, + differs, + ) = _resolve_measuring_point_height(well, None) + + assert resolved_mp_height is None + assert existing_mp_height is None + assert differs is False + + +def test_validate_depth_to_water_against_well_rejects_depth_past_bottom(): + well = _build_well(well_depth=10.0) + + error = _validate_depth_to_water_against_well(4, well, 12.5, 1.0) + + assert ( + error == "Row 4: depth_to_water_ft minus measuring point height (11.5) " + "must be less than well depth (10.0)" + ) + + +def test_validate_depth_to_water_against_well_skips_when_height_unavailable(): + well = _build_well(well_depth=10.0) + + error = _validate_depth_to_water_against_well(4, well, 12.5, None) + + assert error is None + + +def test_build_sample_name_uses_deterministic_well_inventory_style_format(): + row = SimpleNamespace( + well=SimpleNamespace(name="AR0001"), + measurement_dt=datetime(2025, 2, 15, 10, 30, tzinfo=timezone.utc), + ) + + assert _build_sample_name(row) == "AR0001-WL-202502151030" + + +def test_create_records_reports_savepoint_creation_failure_as_row_error(): + class BrokenSession: + def __init__(self): + self.expire_all_called = False + + def begin_nested(self): + raise RuntimeError("savepoint failed") + + def expire_all(self): + self.expire_all_called = True + + session = BrokenSession() + + created, errors = _create_records( + session, + parameter_id=1, + rows=[SimpleNamespace(row_index=7)], + ) + + assert created == [] + assert errors == ["Row 7: savepoint failed"] + assert session.expire_all_called is True + + +def test_bulk_upload_water_levels_is_idempotent(water_well_thing): + csv_content = "\n".join( + [ + ",".join( + [ + "field_staff", + "well_name_point_id", + "field_event_date_time", + "measurement_date_time", + "sampler", + "sample_method", + "mp_height", + "level_status", + "depth_to_water_ft", + "data_quality", + "water_level_notes", + ] + ), + ",".join( + [ + "A Lopez", + water_well_thing.name, + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Initial measurement", + ] + ), + ] + ) + + first = bulk_upload_water_levels(csv_content.encode("utf-8")) + second = bulk_upload_water_levels(csv_content.encode("utf-8")) + + assert first.exit_code == 0, first.payload + assert second.exit_code == 0, second.payload + assert ( + first.payload["water_levels"][0]["sample_id"] + == second.payload["water_levels"][0]["sample_id"] + ) + assert ( + first.payload["water_levels"][0]["observation_id"] + == second.payload["water_levels"][0]["observation_id"] + ) + + with session_ctx() as session: + samples = session.scalars( + select(Sample) + .join(FieldActivity, Sample.field_activity_id == FieldActivity.id) + .join(FieldEvent, FieldActivity.field_event_id == FieldEvent.id) + .join(Thing, FieldEvent.thing_id == Thing.id) + .where( + Thing.id == water_well_thing.id, + FieldActivity.activity_type == "groundwater level", + ) + ).all() + observations = session.scalars( + select(Observation) + .join(Sample, Observation.sample_id == Sample.id) + .join(FieldActivity, Sample.field_activity_id == FieldActivity.id) + .join(FieldEvent, FieldActivity.field_event_id == FieldEvent.id) + .join(Thing, FieldEvent.thing_id == Thing.id) + .where( + Thing.id == water_well_thing.id, + FieldActivity.activity_type == "groundwater level", + ) + ).all() + + assert len(samples) == 1 + assert len(observations) == 1 + assert samples[0].sample_name == "Test Well-WL-202502151730" + assert samples[0].sample_matrix == "groundwater" + assert observations[0].groundwater_level_reason == "Water level not affected" + assert ( + observations[0].nma_data_quality + == "Water level accurate to within two hundreths of a foot" + ) + assert observations[0].measuring_point_height == 1.5 + + +def test_bulk_upload_water_levels_warns_when_mp_height_differs_from_history( + water_well_thing, +): + csv_content = "\n".join( + [ + ",".join( + [ + "field_staff", + "well_name_point_id", + "field_event_date_time", + "measurement_date_time", + "sampler", + "sample_method", + "mp_height", + "level_status", + "depth_to_water_ft", + "data_quality", + "water_level_notes", + ] + ), + ",".join( + [ + "A Lopez", + water_well_thing.name, + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Measurement with warning", + ] + ), + ] + ) + + result = bulk_upload_water_levels(csv_content.encode("utf-8")) + + assert result.exit_code == 0, result.payload + assert result.payload["summary"]["total_rows_imported"] == 1 + assert result.payload["summary"]["validation_errors_or_warnings"] == 1 + assert result.payload["validation_errors"] == [ + "Row 1: CSV mp_height (1.5) differs from existing measuring point height " + "(2.0); CSV value will be used" + ] + + +def test_bulk_upload_water_levels_preserves_unrelated_existing_observations( + water_well_thing, +): + groundwater_parameter_id = get_parameter_id("groundwater level", "Field Parameter") + ph_parameter_id = get_parameter_id("pH", "Field Parameter") + + with session_ctx() as session: + well = session.merge(water_well_thing) + field_event = FieldEvent( + thing=well, + event_date=datetime(2025, 2, 15, 15, 0, tzinfo=timezone.utc), + notes="Existing field event", + ) + field_activity = FieldActivity( + field_event=field_event, + activity_type="groundwater level", + notes="Sampler: Original Sampler", + ) + sample = Sample( + field_activity=field_activity, + sample_date=datetime(2025, 2, 15, 17, 30, tzinfo=timezone.utc), + sample_name="Test Well-WL-202502151730", + sample_matrix="groundwater", + sample_method="Electric tape measurement (E-probe)", + qc_type="Normal", + notes="Existing sample", + ) + unrelated_observation = Observation( + sample=sample, + observation_datetime=datetime(2025, 2, 15, 17, 30, tzinfo=timezone.utc), + parameter_id=ph_parameter_id, + value=7.2, + unit="dimensionless", + notes="Keep me as pH", + ) + session.add_all([field_event, field_activity, sample, unrelated_observation]) + session.commit() + unrelated_observation_id = unrelated_observation.id + + csv_content = "\n".join( + [ + ",".join( + [ + "field_staff", + "well_name_point_id", + "field_event_date_time", + "measurement_date_time", + "sampler", + "sample_method", + "mp_height", + "level_status", + "depth_to_water_ft", + "data_quality", + "water_level_notes", + ] + ), + ",".join( + [ + "A Lopez", + water_well_thing.name, + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Imported groundwater level", + ] + ), + ] + ) + + result = bulk_upload_water_levels(csv_content.encode("utf-8")) + + assert result.exit_code == 0, result.payload + + with session_ctx() as session: + sample = session.scalars( + select(Sample) + .join(FieldActivity, Sample.field_activity_id == FieldActivity.id) + .join(FieldEvent, FieldActivity.field_event_id == FieldEvent.id) + .join(Thing, FieldEvent.thing_id == Thing.id) + .where( + Thing.id == water_well_thing.id, + FieldActivity.activity_type == "groundwater level", + Sample.sample_name == "Test Well-WL-202502151730", + ) + ).one() + observations = session.scalars( + select(Observation) + .where(Observation.sample_id == sample.id) + .order_by(Observation.id.asc()) + ).all() + + assert len(observations) == 2 + assert observations[0].id == unrelated_observation_id + assert observations[0].parameter_id == ph_parameter_id + assert observations[0].value == 7.2 + assert observations[0].unit == "dimensionless" + assert observations[0].notes == "Keep me as pH" + + groundwater_observations = [ + observation + for observation in observations + if observation.parameter_id == groundwater_parameter_id + ] + assert len(groundwater_observations) == 1 + assert ( + groundwater_observations[0].id + == result.payload["water_levels"][0]["observation_id"] + ) + assert groundwater_observations[0].value == 7.0 + assert groundwater_observations[0].unit == "ft" + assert groundwater_observations[0].notes == "Imported groundwater level" + + +def test_bulk_upload_water_levels_imports_valid_rows_when_other_rows_fail( + water_well_thing, +): + csv_content = "\n".join( + [ + ",".join( + [ + "field_staff", + "well_name_point_id", + "field_event_date_time", + "measurement_date_time", + "sampler", + "sample_method", + "mp_height", + "level_status", + "depth_to_water_ft", + "data_quality", + "water_level_notes", + ] + ), + ",".join( + [ + "A Lopez", + water_well_thing.name, + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Initial measurement", + ] + ), + ",".join( + [ + "A Lopez", + "Unknown Well", + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Bad row", + ] + ), + ] + ) + + result = bulk_upload_water_levels(csv_content.encode("utf-8")) + + assert result.exit_code == 0 + assert result.payload["summary"]["total_rows_processed"] == 2 + assert result.payload["summary"]["total_rows_imported"] == 1 + assert result.payload["summary"]["validation_errors_or_warnings"] == 2 + assert len(result.payload["water_levels"]) == 1 + assert len(result.payload["validation_errors"]) == 2 + assert any( + "CSV mp_height (1.5) differs from existing measuring point height (2.0)" + in message + for message in result.payload["validation_errors"] + ) + assert any( + "Unknown well_name_point_id 'Unknown Well'" in message + for message in result.payload["validation_errors"] + ) + + +def test_bulk_upload_water_levels_reports_duplicate_well_name_matches(): + with session_ctx() as session: + well_one = Thing(name="Duplicate Well", thing_type="water well") + well_two = Thing(name="Duplicate Well", thing_type="water well") + session.add_all([well_one, well_two]) + session.commit() + well_one_id = well_one.id + well_two_id = well_two.id + + csv_content = "\n".join( + [ + ",".join( + [ + "field_staff", + "well_name_point_id", + "field_event_date_time", + "measurement_date_time", + "sampler", + "sample_method", + "mp_height", + "level_status", + "depth_to_water_ft", + "data_quality", + "water_level_notes", + ] + ), + ",".join( + [ + "A Lopez", + "Duplicate Well", + "2025-02-15T08:00:00-07:00", + "2025-02-15T10:30:00-07:00", + "A Lopez", + "electric tape", + "1.5", + "Water level not affected", + "7.0", + "Water level accurate to within two hundreths of a foot", + "Initial measurement", + ] + ), + ] + ) + + try: + result = bulk_upload_water_levels(csv_content.encode("utf-8")) + + assert result.exit_code == 1 + assert result.payload["summary"]["total_rows_processed"] == 1 + assert result.payload["summary"]["total_rows_imported"] == 0 + assert result.payload["validation_errors"] == [ + "Row 1: Multiple wells found for well_name_point_id 'Duplicate Well'" + ] + finally: + with session_ctx() as session: + for well_id in (well_one_id, well_two_id): + well = session.get(Thing, well_id) + if well is not None: + session.delete(well) + session.commit() diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py index 010d4d6e..4e3be31b 100644 --- a/tests/test_well_inventory.py +++ b/tests/test_well_inventory.py @@ -14,10 +14,15 @@ import pytest from cli.service_adapter import well_inventory_csv from core.constants import SRID_UTM_ZONE_13N, SRID_WGS84 +from core.enums import Role, ContactType from db import ( + Base, Location, LocationThingAssociation, Thing, + Group, + Sample, + Observation, Contact, ThingContactAssociation, FieldEvent, @@ -25,11 +30,46 @@ FieldEventParticipant, ) from db.engine import session_ctx +from schemas.well_inventory import WellInventoryRow from services.util import transform_srid, convert_ft_to_m from shapely import Point -def test_well_inventory_db_contents(): +def _minimal_valid_well_inventory_row(): + return { + "project": "Test Project", + "well_name_point_id": "TEST-0001", + "site_name": "Test Site", + "date_time": "2025-02-15T10:30:00", + "field_staff": "Test Staff", + "utm_easting": 357000, + "utm_northing": 3784000, + "utm_zone": "13N", + "elevation_ft": 5000, + "elevation_method": "Global positioning system (GPS)", + "measuring_point_height_ft": 3.5, + } + + +def _reset_well_inventory_tables() -> None: + with session_ctx() as session: + for table in reversed(Base.metadata.sorted_tables): + if table.name in ("alembic_version", "parameter"): + continue + if table.name.startswith("lexicon"): + continue + session.execute(table.delete()) + session.commit() + + +@pytest.fixture(autouse=True) +def isolate_well_inventory_tables(): + _reset_well_inventory_tables() + yield + _reset_well_inventory_tables() + + +def test_well_inventory_db_contents_no_waterlevels(): """ Test that the well inventory upload creates the correct database contents. @@ -132,6 +172,7 @@ def test_well_inventory_db_contents(): [ file_content["well_measuring_notes"], file_content["sampling_scenario_notes"], + f"Sample possible: {file_content['sample_possible']}", ] ) assert sorted(c.content for c in thing._get_notes("Historical")) == sorted( @@ -417,16 +458,328 @@ def test_well_inventory_db_contents(): else: assert participant.participant.name == file_content["field_staff_2"] - # CLEAN UP THE DATABASE AFTER TESTING - session.query(FieldEventParticipant).delete() - session.query(FieldActivity).delete() - session.query(FieldEvent).delete() - session.query(ThingContactAssociation).delete() - session.query(LocationThingAssociation).delete() - session.query(Contact).delete() - session.query(Location).delete() - session.query(Thing).delete() - session.commit() + +def test_well_inventory_db_contents_with_waterlevels(tmp_path): + """ + Tests that the following records are made: + + - field event + - field activity for well inventory + - field activity for water level measurement + - field participants + - contact + - location + - thing + - sample + - observation + + """ + row = _minimal_valid_well_inventory_row() + row.update( + { + "water_level_date_time": "2025-02-15T10:30:00", + "depth_to_water_ft": "8", + "sample_method": "Steel-tape measurement", + "data_quality": "Water level accurate to within two hundreths of a foot", + "water_level_notes": "Attempted measurement", + "mp_height_ft": 3.5, + "level_status": "Water level not affected", + } + ) + file_path = tmp_path / "well-inventory-blank-depth.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 0, result.stderr + + with session_ctx() as session: + field_events = session.query(FieldEvent).all() + field_activities = session.query(FieldActivity).all() + field_event_participants = session.query(FieldEventParticipant).all() + contacts = session.query(Contact).all() + locations = session.query(Location).all() + things = session.query(Thing).all() + samples = session.query(Sample).all() + observations = session.query(Observation).all() + + assert len(field_events) == 1 + assert len(field_activities) == 2 + activity_types = {fa.activity_type for fa in field_activities} + assert activity_types == { + "well inventory", + "groundwater level", + }, f"Unexpected activity types: {activity_types}" + gwl_field_activity = next( + (fa for fa in field_activities if fa.activity_type == "groundwater level"), + None, + ) + assert gwl_field_activity is not None + + assert len(field_event_participants) == 1 + assert len(contacts) == 1 + assert len(locations) == 1 + assert len(things) == 1 + assert len(samples) == 1 + sample = samples[0] + assert sample.field_activity == gwl_field_activity + assert len(observations) == 1 + observation = observations[0] + assert observation.sample == sample + + +def test_measuring_point_height_ft_used_for_thing_and_observation(tmp_path): + """When measuring_point_height_ft is provided it is used for the thing's (MeasuringPointHistory) and observation's measuring_point_height values.""" + row = _minimal_valid_well_inventory_row() + row.update( + { + "measuring_point_height_ft": 3.5, + "water_level_date_time": "2025-02-15T10:30:00", + "depth_to_water_ft": "8", + "sample_method": "Steel-tape measurement", + "data_quality": "Water level accurate to within two hundreths of a foot", + "water_level_notes": "Attempted measurement", + } + ) + + file_path = tmp_path / "well-inventory-blank-depth.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 0, result.stderr + + with session_ctx() as session: + things = session.query(Thing).all() + observations = session.query(Observation).all() + + assert len(things) == 1 + assert things[0].measuring_point_height == 3.5 + assert len(observations) == 1 + assert observations[0].measuring_point_height == 3.5 + + +def test_mp_height_used_for_thing_and_observation_when_measuring_point_height_ft_blank( + tmp_path, +): + """When depth to water is provided and measuring_point_height_ft is blank the mp_height value should be used for the thing's (MeasuringPointHistory) and observation's measuring_point_height.""" + row = _minimal_valid_well_inventory_row() + row.update( + { + "measuring_point_height_ft": "", + "water_level_date_time": "2025-02-15T10:30:00", + "depth_to_water_ft": "8", + "sample_method": "Steel-tape measurement", + "data_quality": "Water level accurate to within two hundreths of a foot", + "water_level_notes": "Attempted measurement", + "mp_height": 4.0, + } + ) + + file_path = tmp_path / "well-inventory-blank-depth.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 0, result.stderr + + with session_ctx() as session: + things = session.query(Thing).all() + observations = session.query(Observation).all() + + assert len(things) == 1 + assert things[0].measuring_point_height == 4.0 + assert len(observations) == 1 + assert observations[0].measuring_point_height == 4.0 + + +def test_null_mp_height_allowed(tmp_path): + """A null measuring_point_height_ft and mp_height are allowed when depth to water is provided, and results in null measuring_point_height for the thing and observation.""" + row = _minimal_valid_well_inventory_row() + row.update( + { + "measuring_point_height_ft": "", + "water_level_date_time": "2025-02-15T10:30:00", + "depth_to_water_ft": 8, + "sample_method": "Steel-tape measurement", + "data_quality": "Water level accurate to within two hundreths of a foot", + "water_level_notes": "Attempted measurement", + } + ) + + file_path = tmp_path / "well-inventory-blank-depth.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 0, result.stderr + + with session_ctx() as session: + things = session.query(Thing).all() + observations = session.query(Observation).all() + + assert len(things) == 1 + assert things[0].measuring_point_height is None + assert len(observations) == 1 + assert observations[0].value == 8 + assert observations[0].measuring_point_height is None + + +def test_conflicting_mp_heights_raises_error(tmp_path): + """ + When both measuring_point_height_ft and mp_height are provided, an inequality (conflict) should raise an error. + """ + row = _minimal_valid_well_inventory_row() + + row.update( + { + "measuring_point_height_ft": 3.5, + "mp_height": 4.0, + } + ) + + file_path = tmp_path / "well-inventory-blank-depth.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 1, result.stderr + assert ( + result.payload["validation_errors"][0]["error"] + == "Conflicting values for measuring point height: mp_height and measuring_point_height_ft" + ) + + +def test_blank_depth_to_water_still_creates_water_level_records(tmp_path): + """Blank depth-to-water is treated as missing while preserving the attempted measurement.""" + row = _minimal_valid_well_inventory_row() + row.update( + { + "water_level_date_time": "2025-02-15T10:30:00", + "depth_to_water_ft": "", + "sample_method": "Steel-tape measurement", + "data_quality": "Water level accurate to within two hundreths of a foot", + "water_level_notes": "Attempted measurement", + "mp_height_ft": 3.5, + } + ) + + file_path = tmp_path / "well-inventory-blank-depth.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 0, result.stderr + + with session_ctx() as session: + samples = session.query(Sample).all() + observations = session.query(Observation).all() + + assert len(samples) == 1 + assert len(observations) == 1 + assert samples[0].sample_date == datetime.fromisoformat("2025-02-15T10:30:00Z") + assert observations[0].observation_datetime == datetime.fromisoformat( + "2025-02-15T10:30:00Z" + ) + assert observations[0].value is None + assert observations[0].measuring_point_height == 3.5 + + +def test_rerunning_same_well_inventory_csv_is_idempotent(): + """Re-importing the same CSV should not create duplicate well inventory records.""" + file = Path("tests/features/data/well-inventory-valid.csv") + assert file.exists(), "Test data file does not exist." + + first = well_inventory_csv(file) + assert first.exit_code == 0, first.stderr + + with session_ctx() as session: + counts_after_first = { + "things": session.query(Thing).count(), + "field_events": session.query(FieldEvent).count(), + "field_activities": session.query(FieldActivity).count(), + "samples": session.query(Sample).count(), + "observations": session.query(Observation).count(), + } + + second = well_inventory_csv(file) + assert second.exit_code == 0, second.stderr + + with session_ctx() as session: + counts_after_second = { + "things": session.query(Thing).count(), + "field_events": session.query(FieldEvent).count(), + "field_activities": session.query(FieldActivity).count(), + "samples": session.query(Sample).count(), + "observations": session.query(Observation).count(), + } + + assert counts_after_second == counts_after_first + + +def test_failed_project_rows_do_not_create_empty_group(tmp_path): + row = _minimal_valid_well_inventory_row() + row.update( + { + "project": "Project Without Successful Rows", + "repeat_measurement_permission": True, + } + ) + + file_path = tmp_path / "well-inventory-failed-project.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 1, result.stderr + + with session_ctx() as session: + group = ( + session.query(Group) + .filter( + Group.name == "Project Without Successful Rows", + Group.group_type == "Monitoring Plan", + ) + .one_or_none() + ) + + assert group is None + + +def test_complete_monitoring_frequency_sets_not_currently_monitored_without_frequency( + tmp_path, +): + row = _minimal_valid_well_inventory_row() + row["monitoring_frequency"] = "Complete" + + file_path = tmp_path / "well-inventory-complete-monitoring-frequency.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + assert result.exit_code == 0, result.stderr + + with session_ctx() as session: + thing = session.query(Thing).one() + + assert thing.monitoring_status == "Not currently monitored" + assert thing.monitoring_frequencies == [] # ============================================================================= @@ -480,6 +833,42 @@ def test_upload_duplicate_well_ids(self): errors = result.payload.get("validation_errors", []) assert any("Duplicate" in str(e) for e in errors) + def test_upload_fails_when_well_name_already_exists_in_database(self, tmp_path): + """Upload fails when a water well with the same Thing.name already exists.""" + row = _minimal_valid_well_inventory_row() + + with session_ctx() as session: + session.add(Thing(name=row["well_name_point_id"], thing_type="water well")) + session.commit() + + file_path = tmp_path / "well-inventory-existing-db-well.csv" + with file_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(row.keys())) + writer.writeheader() + writer.writerow(row) + + result = well_inventory_csv(file_path) + + assert result.exit_code == 1, result.stderr + errors = result.payload.get("validation_errors", []) + assert errors + assert errors[0]["field"] == "well_name_point_id" + assert ( + errors[0]["error"] + == "Well already exists in database for well_name_point_id 'TEST-0001'" + ) + + with session_ctx() as session: + things = ( + session.query(Thing) + .filter( + Thing.name == row["well_name_point_id"], + Thing.thing_type == "water well", + ) + .all() + ) + assert len(things) == 1 + def test_upload_blank_well_name_point_id_autogenerates(self, tmp_path): """Upload succeeds when well_name_point_id is blank and auto-generates IDs.""" source_path = Path("tests/features/data/well-inventory-valid.csv") @@ -582,7 +971,7 @@ def test_upload_missing_contact_type(self): result = well_inventory_csv(file_path) assert result.exit_code == 1 - def test_upload_missing_contact_type(self): + def test_upload_missing_contact_role(self): """Upload fails when contact is provided without role.""" file_path = Path("tests/features/data/well-inventory-missing-contact-role.csv") if file_path.exists(): @@ -682,8 +1071,8 @@ def test_make_contact_with_full_info(self): model.contact_special_requests_notes = "Call before visiting" model.contact_1_name = "John Doe" model.contact_1_organization = "Test Org" - model.contact_1_role = "Owner" - model.contact_1_type = "Primary" + model.contact_1_role = Role.Owner + model.contact_1_type = ContactType.Primary model.contact_1_email_1 = "john@example.com" model.contact_1_email_1_type = "Work" model.contact_1_email_2 = None @@ -719,15 +1108,38 @@ def test_make_contact_with_full_info(self): assert len(contact_dict["addresses"]) == 1 assert len(contact_dict["notes"]) == 2 - def test_make_contact_with_no_name(self): - """Test contact dict returns None when name is empty.""" + def test_make_contact_with_no_name_or_organization(self): + """Test contact dict returns None when name and organization are empty.""" from services.well_inventory_csv import _make_contact from unittest.mock import MagicMock model = MagicMock() model.result_communication_preference = None model.contact_special_requests_notes = None - model.contact_1_name = None # No name provided + model.contact_1_name = None + model.contact_1_organization = None + model.contact_1_role = None + model.contact_1_type = None + model.contact_1_email_1 = None + model.contact_1_email_1_type = None + model.contact_1_email_2 = None + model.contact_1_email_2_type = None + model.contact_1_phone_1 = None + model.contact_1_phone_1_type = None + model.contact_1_phone_2 = None + model.contact_1_phone_2_type = None + model.contact_1_address_1_line_1 = None + model.contact_1_address_1_line_2 = None + model.contact_1_address_1_city = None + model.contact_1_address_1_state = None + model.contact_1_address_1_postal_code = None + model.contact_1_address_1_type = None + model.contact_1_address_2_line_1 = None + model.contact_1_address_2_line_2 = None + model.contact_1_address_2_city = None + model.contact_1_address_2_state = None + model.contact_1_address_2_postal_code = None + model.contact_1_address_2_type = None well = MagicMock() well.id = 1 @@ -736,6 +1148,53 @@ def test_make_contact_with_no_name(self): assert contact_dict is None + def test_make_contact_with_organization_only(self): + """Test contact dict creation when organization is present without a name.""" + from services.well_inventory_csv import _make_contact + from unittest.mock import MagicMock + + model = MagicMock() + model.result_communication_preference = None + model.contact_special_requests_notes = None + model.contact_1_name = None + model.contact_1_organization = "Test Org" + model.contact_1_role = Role.Owner + model.contact_1_type = ContactType.Primary + model.contact_1_email_1 = None + model.contact_1_email_1_type = None + model.contact_1_email_2 = None + model.contact_1_email_2_type = None + model.contact_1_phone_1 = None + model.contact_1_phone_1_type = None + model.contact_1_phone_2 = None + model.contact_1_phone_2_type = None + model.contact_1_address_1_line_1 = None + model.contact_1_address_1_line_2 = None + model.contact_1_address_1_city = None + model.contact_1_address_1_state = None + model.contact_1_address_1_postal_code = None + model.contact_1_address_1_type = None + model.contact_1_address_2_line_1 = None + model.contact_1_address_2_line_2 = None + model.contact_1_address_2_city = None + model.contact_1_address_2_state = None + model.contact_1_address_2_postal_code = None + model.contact_1_address_2_type = None + + well = MagicMock() + well.id = 1 + + contact_dict = _make_contact(model, well, 1) + + assert contact_dict is not None + assert contact_dict["name"] is None + assert contact_dict["organization"] == "Test Org" + assert contact_dict["thing_id"] == 1 + assert contact_dict["emails"] == [] + assert contact_dict["phones"] == [] + assert contact_dict["addresses"] == [] + assert contact_dict["notes"] == [] + def test_make_well_permission(self): """Test well permission creation.""" from services.well_inventory_csv import _make_well_permission @@ -831,10 +1290,12 @@ def test_extract_autogen_prefix_pattern(self): assert _extract_autogen_prefix("XY-") == "XY-" assert _extract_autogen_prefix("AB-") == "AB-" - # New supported form (2-3 uppercase letter prefixes) + # Placeholder tokens are accepted case-insensitively and normalized. assert _extract_autogen_prefix("WL-XXXX") == "WL-" assert _extract_autogen_prefix("SAC-XXXX") == "SAC-" assert _extract_autogen_prefix("ABC -xxxx") == "ABC-" + assert _extract_autogen_prefix("wl-xxxx") == "WL-" + assert _extract_autogen_prefix("abc - XXXX") == "ABC-" # Blank values use default prefix assert _extract_autogen_prefix("") == "NM-" @@ -846,7 +1307,6 @@ def test_extract_autogen_prefix_pattern(self): assert _extract_autogen_prefix("X-") is None assert _extract_autogen_prefix("123-") is None assert _extract_autogen_prefix("USER-XXXX") is None - assert _extract_autogen_prefix("wl-xxxx") is None def test_make_row_models_missing_well_name_point_id_column_errors(self): """Missing well_name_point_id column should fail validation (blank cell is separate).""" @@ -907,6 +1367,112 @@ def test_group_query_with_multiple_conditions(self): session.commit() +class TestWellInventoryRowAliases: + """Schema alias handling for well inventory CSV field names.""" + + def test_well_status_accepts_well_hole_status_alias(self): + row = _minimal_valid_well_inventory_row() + row["well_hole_status"] = "Abandoned" + + model = WellInventoryRow(**row) + + assert model.well_status.value == "Abandoned" + + def test_invalid_well_status_alias_raises_validation_error(self): + row = _minimal_valid_well_inventory_row() + row["well_hole_status"] = "NotARealWellHoleStatus" + + with pytest.raises(ValueError, match="Input should be"): + WellInventoryRow(**row) + + def test_water_level_aliases_are_mapped(self): + row = _minimal_valid_well_inventory_row() + row.update( + { + "measuring_person": "Tech 1", + "sample_method": "Steel-tape measurement", + "water_level_date_time": "2025-02-15T10:30:00", + "mp_height_ft": 2.5, + "level_status": "Other conditions exist that would affect the level (remarks)", + "depth_to_water_ft": 11.2, + "data_quality": "Water level accurate to within two hundreths of a foot", + "water_level_notes": "Initial reading", + } + ) + + model = WellInventoryRow(**row) + + assert model.sampler == "Tech 1" + assert model.measurement_date_time == datetime.fromisoformat( + "2025-02-15T10:30:00" + ) + assert model.mp_height == 2.5 + assert model.depth_to_water_ft == 11.2 + assert model.water_level_notes == "Initial reading" + + def test_blank_depth_to_water_is_treated_as_none(self): + row = _minimal_valid_well_inventory_row() + row.update( + { + "water_level_date_time": "2025-02-15T10:30:00", + "depth_to_water_ft": "", + } + ) + + model = WellInventoryRow(**row) + + assert model.measurement_date_time == datetime.fromisoformat( + "2025-02-15T10:30:00" + ) + assert model.depth_to_water_ft is None + + def test_blank_contact_organization_is_treated_as_none(self): + row = _minimal_valid_well_inventory_row() + row["contact_1_name"] = "Test Contact" + row["contact_1_organization"] = "" + row["contact_1_role"] = "Owner" + row["contact_1_type"] = "Primary" + + model = WellInventoryRow(**row) + + assert model.contact_1_name == "Test Contact" + assert model.contact_1_organization is None + + def test_blank_well_status_is_treated_as_none(self): + row = _minimal_valid_well_inventory_row() + row["well_hole_status"] = "" + + model = WellInventoryRow(**row) + + assert model.well_status is None + + def test_complete_monitoring_frequency_is_normalized(self): + row = _minimal_valid_well_inventory_row() + row["monitoring_frequency"] = "Complete" + + model = WellInventoryRow(**row) + + assert model.monitoring_frequency is None + assert model.monitoring_status.value == "Not currently monitored" + + def test_whitespace_only_well_status_is_treated_as_none(self): + row = _minimal_valid_well_inventory_row() + row["well_hole_status"] = " " + + model = WellInventoryRow(**row) + + assert model.well_status is None + + def test_canonical_name_wins_when_alias_and_canonical_present(self): + row = _minimal_valid_well_inventory_row() + row["well_status"] = "Abandoned" + row["well_hole_status"] = "Inactive, exists but not used" + + model = WellInventoryRow(**row) + + assert model.well_status.value == "Abandoned" + + class TestWellInventoryAPIEdgeCases: """Additional edge case tests for API endpoints.""" @@ -966,15 +1532,5 @@ def test_upload_valid_with_comma_in_quotes(self): # Should succeed - commas in quoted fields are valid CSV assert result.exit_code in (0, 1) # 1 if other validation fails - # Clean up if records were created - if result.exit_code == 0: - with session_ctx() as session: - session.query(Thing).delete() - session.query(Location).delete() - session.query(Contact).delete() - session.query(FieldEvent).delete() - session.query(FieldActivity).delete() - session.commit() - # ============= EOF ============================================= diff --git a/tests/test_well_transfer.py b/tests/test_well_transfer.py new file mode 100644 index 00000000..373e70b1 --- /dev/null +++ b/tests/test_well_transfer.py @@ -0,0 +1,317 @@ +import threading +from contextlib import contextmanager +from types import SimpleNamespace + +import pandas as pd +import pytest +from sqlalchemy.exc import IntegrityError + +from schemas.thing import CreateWell +from transfers import well_transfer as wt + + +class _FakeSession: + def __init__(self): + self.added = [] + self.expunge_calls = [] + + def add(self, obj): + self.added.append(obj) + + def expunge(self, obj): + self.expunge_calls.append(obj) + + +class _FakeQuery: + def __init__(self, session): + self.session = session + + def filter(self, *_args, **_kwargs): + return self + + def first(self): + return self.session.query_results.pop(0) + + +class _FakeSavepoint: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class _FakeAquiferSession: + def __init__(self, query_results, flush_exc=None): + self.added = [] + self.begin_nested_calls = 0 + self.rollback_calls = 0 + self.query_results = list(query_results) + self.flush_exc = flush_exc + + def add(self, obj): + self.added.append(obj) + + def begin_nested(self): + self.begin_nested_calls += 1 + return _FakeSavepoint() + + def flush(self): + if self.flush_exc is not None: + exc = self.flush_exc + self.flush_exc = None + raise exc + + def query(self, _model): + return _FakeQuery(self) + + def rollback(self): + self.rollback_calls += 1 + + +def test_persist_well_excludes_monitoring_status_from_thing_kwargs( + monkeypatch, +): + captured_kwargs = {} + + class FakeThing: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + + monkeypatch.setattr(wt, "Thing", FakeThing) + + transferer = wt.WellTransferer() + session = _FakeSession() + row = SimpleNamespace(PointID="AR0001", WellID=12, LocationId=34) + payload = { + "data": CreateWell( + name="AR0001", + monitoring_status="Not currently monitored", + ), + "well_purposes": [], + "well_casing_materials": [], + } + batch_errors = [] + + well = transferer._persist_well(session, row, payload, batch_errors) + + assert well is session.added[0] + assert "monitoring_status" not in captured_kwargs + assert captured_kwargs["thing_type"] == "water well" + assert captured_kwargs["nma_pk_welldata"] == 12 + assert captured_kwargs["nma_pk_location"] == 34 + assert batch_errors == [] + assert session.expunge_calls == [] + + +def test_add_aquifers_parallel_recovers_from_integrity_error(monkeypatch): + class FakeAquiferSystem: + name = "name" + + def __init__(self, name, primary_aquifer_type, geographic_scale): + self.name = name + self.primary_aquifer_type = primary_aquifer_type + self.geographic_scale = geographic_scale + + class FakeThingAquiferAssociation: + def __init__(self, thing, aquifer_system): + self.thing = thing + self.aquifer_system = aquifer_system + + class FakeAquiferType: + def __init__(self, thing_aquifer_association, aquifer_type): + self.thing_aquifer_association = thing_aquifer_association + self.aquifer_type = aquifer_type + + def fake_map_value(value): + if value.startswith("LU_AquiferClass:"): + return "Test Aquifer" + if value.startswith("LU_AquiferType:"): + return "Confined" + raise KeyError(value) + + existing_aquifer = SimpleNamespace(name="Test Aquifer") + session = _FakeAquiferSession( + query_results=[None, existing_aquifer], + flush_exc=IntegrityError("insert", {}, Exception("duplicate key")), + ) + transferer = wt.WellTransferer() + row = SimpleNamespace(PointID="AR0001", AqClass="AQ", AquiferType="A") + well = SimpleNamespace(name="AR0001") + local_aquifers = [] + + monkeypatch.setattr(wt, "AquiferSystem", FakeAquiferSystem) + monkeypatch.setattr(wt, "ThingAquiferAssociation", FakeThingAquiferAssociation) + monkeypatch.setattr(wt, "AquiferType", FakeAquiferType) + monkeypatch.setattr(wt, "extract_aquifer_type_codes", lambda _value: ["A"]) + monkeypatch.setattr(wt.lexicon_mapper, "map_value", fake_map_value) + + transferer._add_aquifers_parallel( + session, row, well, local_aquifers, threading.Lock() + ) + + associations = [ + obj for obj in session.added if isinstance(obj, FakeThingAquiferAssociation) + ] + + assert session.begin_nested_calls == 1 + assert session.rollback_calls == 0 + assert associations[0].aquifer_system is existing_aquifer + assert local_aquifers == [existing_aquifer] + + +def test_add_aquifers_parallel_reraises_unexpected_flush_errors(monkeypatch): + class FakeAquiferSystem: + name = "name" + + def __init__(self, name, primary_aquifer_type, geographic_scale): + self.name = name + self.primary_aquifer_type = primary_aquifer_type + self.geographic_scale = geographic_scale + + def fake_map_value(value): + if value.startswith("LU_AquiferClass:"): + return "Test Aquifer" + if value.startswith("LU_AquiferType:"): + return "Confined" + raise KeyError(value) + + session = _FakeAquiferSession( + query_results=[None], + flush_exc=RuntimeError("database unavailable"), + ) + transferer = wt.WellTransferer() + row = SimpleNamespace(PointID="AR0001", AqClass="AQ", AquiferType="A") + well = SimpleNamespace(name="AR0001") + + monkeypatch.setattr(wt, "AquiferSystem", FakeAquiferSystem) + monkeypatch.setattr(wt, "extract_aquifer_type_codes", lambda _value: ["A"]) + monkeypatch.setattr(wt.lexicon_mapper, "map_value", fake_map_value) + + with pytest.raises(RuntimeError, match="database unavailable"): + transferer._add_aquifers_parallel(session, row, well, [], threading.Lock()) + + assert session.begin_nested_calls == 1 + assert session.rollback_calls == 0 + + +def test_transfer_parallel_preloads_cached_elevations_before_worker_submission( + monkeypatch, +): + class FakePreloadSession: + def query(self, _model): + return self + + def all(self): + return [] + + def expunge_all(self): + pass + + class FakeFuture: + def result(self): + return {"errors": []} + + class FakeExecutor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def submit(self, fn, idx, batch): + assert transferer._cached_elevations == {"source": "preloaded"} + return FakeFuture() + + @contextmanager + def fake_session_ctx(): + yield FakePreloadSession() + + load_calls = [] + dumped = [] + + def fake_get_cached_elevations(): + load_calls.append("load") + return {"source": "preloaded"} + + def fake_dump_cached_elevations(lut): + dumped.append(lut) + + transferer = wt.WellTransferer() + df = pd.DataFrame([{"PointID": "AR0001"}]) + + monkeypatch.setattr(wt, "session_ctx", fake_session_ctx) + monkeypatch.setattr(wt, "get_cached_elevations", fake_get_cached_elevations) + monkeypatch.setattr(wt, "dump_cached_elevations", fake_dump_cached_elevations) + monkeypatch.setattr(wt, "MeasuringPointEstimator", lambda: object()) + monkeypatch.setattr(wt, "ThreadPoolExecutor", lambda max_workers: FakeExecutor()) + monkeypatch.setattr(wt, "as_completed", lambda futures: list(futures)) + monkeypatch.setattr(transferer, "_get_dfs", lambda: (df, df.copy())) + + transferer.transfer_parallel(num_workers=2) + + assert load_calls == ["load"] + assert dumped == [{"source": "preloaded"}] + + +def test_transfer_parallel_preloads_measuring_point_estimator_before_workers( + monkeypatch, +): + class FakePreloadSession: + def query(self, _model): + return self + + def all(self): + return [] + + def expunge_all(self): + pass + + class FakeFuture: + def result(self): + return {"errors": []} + + class FakeExecutor: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def submit(self, fn, idx, batch): + assert transferer._measuring_point_estimator is estimator + return FakeFuture() + + @contextmanager + def fake_session_ctx(): + yield FakePreloadSession() + + dumped = [] + estimator = object() + build_calls = [] + + def fake_get_cached_elevations(): + return {} + + def fake_dump_cached_elevations(lut): + dumped.append(lut) + + def fake_estimator_ctor(): + build_calls.append("build") + return estimator + + transferer = wt.WellTransferer() + df = pd.DataFrame([{"PointID": "AR0001"}]) + + monkeypatch.setattr(wt, "session_ctx", fake_session_ctx) + monkeypatch.setattr(wt, "get_cached_elevations", fake_get_cached_elevations) + monkeypatch.setattr(wt, "dump_cached_elevations", fake_dump_cached_elevations) + monkeypatch.setattr(wt, "MeasuringPointEstimator", fake_estimator_ctor) + monkeypatch.setattr(wt, "ThreadPoolExecutor", lambda max_workers: FakeExecutor()) + monkeypatch.setattr(wt, "as_completed", lambda futures: list(futures)) + monkeypatch.setattr(transferer, "_get_dfs", lambda: (df, df.copy())) + + transferer.transfer_parallel(num_workers=2) + + assert build_calls == ["build"] + assert dumped == [{}] diff --git a/tests/transfers/test_contact_with_multiple_wells.py b/tests/transfers/test_contact_with_multiple_wells.py index 40b4b26e..60af21b5 100644 --- a/tests/transfers/test_contact_with_multiple_wells.py +++ b/tests/transfers/test_contact_with_multiple_wells.py @@ -93,6 +93,8 @@ def test_owner_comment_absent_skips_notes(): def test_ownerkey_fallback_name_when_name_and_org_missing(water_well_thing): with session_ctx() as sess: thing = sess.get(Thing, water_well_thing.id) + contact_by_owner_type = {} + contact_by_name_org = {} row = SimpleNamespace( FirstName=None, LastName=None, @@ -118,12 +120,18 @@ def test_ownerkey_fallback_name_when_name_and_org_missing(water_well_thing): # Should not raise "Either name or organization must be provided." contact = _add_first_contact( - sess, row=row, thing=thing, organization=None, added=[] + sess, + row=row, + thing=thing, + organization=None, + added=set(), + contact_by_owner_type=contact_by_owner_type, + contact_by_name_org=contact_by_name_org, ) sess.flush() assert contact is not None - assert contact.name == "Fallback OwnerKey Name" + assert contact.name == "Fallback OwnerKey Name-primary" assert contact.organization is None @@ -131,6 +139,8 @@ def test_ownerkey_dedupes_when_fallback_name_differs(water_well_thing): owner_key = f"OwnerKey-{uuid4()}" with session_ctx() as sess: first_thing = sess.get(Thing, water_well_thing.id) + contact_by_owner_type = {} + contact_by_name_org = {} second_thing = Thing( name=f"Second Well {uuid4()}", thing_type="water well", @@ -184,15 +194,27 @@ def test_ownerkey_dedupes_when_fallback_name_differs(water_well_thing): PhysicalZipCode=None, ) - added = [] + added = set() first_contact = _add_first_contact( - sess, row=complete_row, thing=first_thing, organization=None, added=added + sess, + row=complete_row, + thing=first_thing, + organization=None, + added=added, + contact_by_owner_type=contact_by_owner_type, + contact_by_name_org=contact_by_name_org, ) assert first_contact is not None assert first_contact.name == "Casey Owner" second_contact = _add_first_contact( - sess, row=fallback_row, thing=second_thing, organization=None, added=added + sess, + row=fallback_row, + thing=second_thing, + organization=None, + added=added, + contact_by_owner_type=contact_by_owner_type, + contact_by_name_org=contact_by_name_org, ) sess.flush() diff --git a/transfers/backfill/backfill.py b/transfers/backfill/backfill.py index fc7f5026..289f07d5 100644 --- a/transfers/backfill/backfill.py +++ b/transfers/backfill/backfill.py @@ -29,7 +29,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from services.util import get_bool_env +from services.env import get_bool_env from transfers.logger import logger diff --git a/transfers/transfer.py b/transfers/transfer.py index 49e36e9a..419d4870 100644 --- a/transfers/transfer.py +++ b/transfers/transfer.py @@ -52,7 +52,7 @@ from db.engine import session_ctx from db.initialization import recreate_public_schema, sync_search_vector_triggers -from services.util import get_bool_env +from services.env import get_bool_env from transfers.aquifer_system_transfer import transfer_aquifer_systems from transfers.geologic_formation_transfer import transfer_geologic_formations from transfers.permissions_transfer import transfer_permissions diff --git a/transfers/well_transfer.py b/transfers/well_transfer.py index d8e1c200..e477eb3b 100644 --- a/transfers/well_transfer.py +++ b/transfers/well_transfer.py @@ -25,7 +25,7 @@ import pandas as pd from pandas import isna, notna from pydantic import ValidationError -from sqlalchemy.exc import DatabaseError +from sqlalchemy.exc import DatabaseError, IntegrityError from sqlalchemy.orm import Session from core.enums import ( @@ -93,6 +93,7 @@ "is_suitable_for_datalogger", "is_open", "well_status", + "monitoring_status", ] @@ -137,12 +138,24 @@ class WellTransferer(Transferer): def __init__(self, *args, **kw): super().__init__(*args, **kw) - self._cached_elevations = get_cached_elevations() + # Delay external I/O so unit tests can instantiate the transferer + # without requiring GCS credentials or source CSV files. + self._cached_elevations = None self._added_locations = {} self._aquifers = None - self._measuring_point_estimator = MeasuringPointEstimator() + self._measuring_point_estimator = None self._row_by_pointid: dict[str, pd.Series] = {} + def _get_cached_elevations(self) -> dict: + if self._cached_elevations is None: + self._cached_elevations = get_cached_elevations() + return self._cached_elevations + + def _get_measuring_point_estimator(self) -> MeasuringPointEstimator: + if self._measuring_point_estimator is None: + self._measuring_point_estimator = MeasuringPointEstimator() + return self._measuring_point_estimator + def transfer_parallel(self, num_workers: int = None) -> None: """ Transfer wells using parallel processing for improved performance. @@ -170,6 +183,11 @@ def transfer_parallel(self, num_workers: int = None) -> None: logger.info("No wells to transfer") return + # Pre-load shared cached elevations on the main thread so workers + # mutate a single cache instance instead of racing lazy initialization. + self._get_cached_elevations() + self._get_measuring_point_estimator() + # Calculate batch size batch_size = max(100, n // num_workers) batches = [df.iloc[i : i + batch_size] for i in range(0, n, batch_size)] @@ -299,7 +317,7 @@ def process_batch(batch_idx: int, batch_df: pd.DataFrame) -> dict: logger.info(f"Parallel transfer complete: {n} wells, {len(all_errors)} errors") # Dump cached elevations (minimal after-processing) - dump_cached_elevations(self._cached_elevations) + dump_cached_elevations(self._get_cached_elevations()) def _get_dfs(self): """Load and clean WellData/Location dataframes.""" @@ -657,7 +675,7 @@ def _persist_location(self, session: Session, row, batch_errors: list): """Create a Location from the legacy row.""" try: location, elevation_method, location_notes = make_location( - row, self._cached_elevations + row, self._get_cached_elevations() ) session.add(location) return location, elevation_method, location_notes @@ -744,7 +762,11 @@ def _add_histories(self, session: Session, row, well: Thing) -> None: ) ) else: - mphs = self._measuring_point_estimator.estimate_measuring_point_height(row) + mphs = ( + self._get_measuring_point_estimator().estimate_measuring_point_height( + row + ) + ) added_measuring_point = False for mph, mph_desc, start_date, end_date in zip(*mphs): session.add( @@ -885,12 +907,9 @@ def _step_parallel_complete( # Aquifers if notna(row.AquiferType): - try: - self._add_aquifers_parallel( - session, row, well, local_aquifers, aquifers_lock - ) - except Exception as e: - logger.warning(f"Error adding aquifer for {well.name}: {e}") + self._add_aquifers_parallel( + session, row, well, local_aquifers, aquifers_lock + ) # Formation zone formation_code = row.FormationZone if hasattr(row, "FormationZone") else None @@ -989,13 +1008,14 @@ def _add_aquifers_parallel(self, session, row, well, local_aquifers, aquifers_lo if not aquifer: try: - aquifer = AquiferSystem( - name=aquifer_name, - primary_aquifer_type=primary_type, - geographic_scale=None, - ) - session.add(aquifer) - session.flush() + with session.begin_nested(): + aquifer = AquiferSystem( + name=aquifer_name, + primary_aquifer_type=primary_type, + geographic_scale=None, + ) + session.add(aquifer) + session.flush() logger.info(f"Created aquifer: {aquifer_name}") # Update local cache under lock @@ -1006,14 +1026,23 @@ def _add_aquifers_parallel(self, session, row, well, local_aquifers, aquifers_lo ) if not existing: local_aquifers.append(aquifer) - except Exception as e: + except IntegrityError: # Race condition - another thread created it - session.rollback() aquifer = ( session.query(AquiferSystem) .filter(AquiferSystem.name == aquifer_name) .first() ) + if aquifer: + with aquifers_lock: + existing = next( + (a for a in local_aquifers if a.name == aquifer_name), + None, + ) + if not existing: + local_aquifers.append(aquifer) + else: + raise if aquifer: aquifer_assoc = ThingAquiferAssociation(thing=well, aquifer_system=aquifer) diff --git a/uv.lock b/uv.lock index eb03c232..bc8fea56 100644 --- a/uv.lock +++ b/uv.lock @@ -31,7 +31,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -42,59 +42,59 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" }, + { url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" }, + { url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" }, + { url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" }, + { url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" }, + { url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" }, + { url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" }, + { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, + { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, + { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, + { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, + { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, + { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, + { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, + { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, + { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, ] [[package]] @@ -152,28 +152,28 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] name = "apitally" -version = "0.24.1" +version = "0.24.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, { name = "opentelemetry-sdk" }, { name = "psutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/a0/f3d66fc04d5cc6de2b4c45534329c70fe506f63f0ffc2603ed485584c456/apitally-0.24.1.tar.gz", hash = "sha256:18d476871e081ff8f42fd0b631b33ccaf631be404abe9a54e30621117389a70e", size = 220724, upload-time = "2026-02-16T12:44:06.635Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/1c/949fb90a4d0475028480433116a481208be05ce06e40afcae050e6053a29/apitally-0.24.4.tar.gz", hash = "sha256:78447204cb1b0e6b409129ae8b13ddcdfe03bab648af8662cd73fc24a8e30ec2", size = 225388, upload-time = "2026-03-30T03:29:09.334Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/c8/2b2d566edf46b5a50bd3178770089269d1dcf17f4398157b35c9f54c02c3/apitally-0.24.1-py3-none-any.whl", hash = "sha256:90adc1ad7698e83833622f4673e72c46e39c9474385a891dd3ce4e413c1f0863", size = 47829, upload-time = "2026-02-16T12:44:08.833Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/49690a550caae96dcd6b4e376d1d0e094c180eb1ee1224fe26b8956aecb8/apitally-0.24.4-py3-none-any.whl", hash = "sha256:764f3c9dc907ec2014f8f420d66db091826106eb7b306ce871238c647029a019", size = 48067, upload-time = "2026-03-30T03:29:10.538Z" }, ] [package.optional-dependencies] @@ -244,14 +244,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.8" +version = "1.6.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/6c/c88eac87468c607f88bc24df1f3b31445ee6fc9ba123b09e666adf687cd9/authlib-1.6.8.tar.gz", hash = "sha256:41ae180a17cf672bc784e4a518e5c82687f1fe1e98b0cafaeda80c8e4ab2d1cb", size = 165074, upload-time = "2026-02-14T04:02:17.941Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/73/f7084bf12755113cd535ae586782ff3a6e710bfbe6a0d13d1c2f81ffbbfa/authlib-1.6.8-py2.py3-none-any.whl", hash = "sha256:97286fd7a15e6cfefc32771c8ef9c54f0ed58028f1322de6a2a7c969c3817888", size = 244116, upload-time = "2026-02-14T04:02:15.579Z" }, + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] [[package]] @@ -341,7 +341,7 @@ wheels = [ [[package]] name = "black" -version = "26.1.0" +version = "26.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -351,19 +351,19 @@ dependencies = [ { name = "platformdirs" }, { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/88/560b11e521c522440af991d46848a2bde64b5f7202ec14e1f46f9509d328/black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58", size = 658785, upload-time = "2026-01-18T04:50:11.993Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/04/fa2f4784f7237279332aa735cdfd5ae2e7730db0072fb2041dadda9ae551/black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304", size = 1877781, upload-time = "2026-01-18T04:59:39.054Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ad/5a131b01acc0e5336740a039628c0ab69d60cf09a2c87a4ec49f5826acda/black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9", size = 1699670, upload-time = "2026-01-18T04:59:41.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/7c/b05f22964316a52ab6b4265bcd52c0ad2c30d7ca6bd3d0637e438fc32d6e/black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", size = 1775212, upload-time = "2026-01-18T04:59:42.545Z" }, - { url = "https://files.pythonhosted.org/packages/a6/a3/e8d1526bea0446e040193185353920a9506eab60a7d8beb062029129c7d2/black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", size = 1409953, upload-time = "2026-01-18T04:59:44.357Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5a/d62ebf4d8f5e3a1daa54adaab94c107b57be1b1a2f115a0249b41931e188/black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", size = 1217707, upload-time = "2026-01-18T04:59:45.719Z" }, - { url = "https://files.pythonhosted.org/packages/6a/83/be35a175aacfce4b05584ac415fd317dd6c24e93a0af2dcedce0f686f5d8/black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", size = 1871864, upload-time = "2026-01-18T04:59:47.586Z" }, - { url = "https://files.pythonhosted.org/packages/a5/f5/d33696c099450b1274d925a42b7a030cd3ea1f56d72e5ca8bbed5f52759c/black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", size = 1701009, upload-time = "2026-01-18T04:59:49.443Z" }, - { url = "https://files.pythonhosted.org/packages/1b/87/670dd888c537acb53a863bc15abbd85b22b429237d9de1b77c0ed6b79c42/black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", size = 1767806, upload-time = "2026-01-18T04:59:50.769Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9c/cd3deb79bfec5bcf30f9d2100ffeec63eecce826eb63e3961708b9431ff1/black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", size = 1433217, upload-time = "2026-01-18T04:59:52.218Z" }, - { url = "https://files.pythonhosted.org/packages/4e/29/f3be41a1cf502a283506f40f5d27203249d181f7a1a2abce1c6ce188035a/black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", size = 1245773, upload-time = "2026-01-18T04:59:54.457Z" }, - { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] [[package]] @@ -395,24 +395,47 @@ wheels = [ [[package]] name = "cffi" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] @@ -426,43 +449,59 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] [[package]] @@ -491,7 +530,7 @@ wheels = [ [[package]] name = "cloud-sql-python-connector" -version = "1.20.0" +version = "1.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -501,9 +540,9 @@ dependencies = [ { name = "google-auth" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/9a/b349d7fe9d4dd5f7b72d58b1b3c422d4e3e62854c5871355b7f4faf66281/cloud_sql_python_connector-1.20.0.tar.gz", hash = "sha256:fdd96153b950040b0252453115604c142922b72cf3636146165a648ac5f6fc30", size = 44208, upload-time = "2026-01-13T01:09:11.405Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/cd/e54ca42ea4f386571ab99cb363e15d45b2c1228dc6f8120101e6cd6db9e1/cloud_sql_python_connector-1.20.1.tar.gz", hash = "sha256:7e826875c5c284e1dfd872ab81d8c75eb82dd67ad1bbf43b9e74489342765255", size = 44235, upload-time = "2026-03-16T16:53:04.927Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/1a/5d5015c7c1175d9abf985c07b0665151394c497649ba8026985ba7aba26b/cloud_sql_python_connector-1.20.0-py3-none-any.whl", hash = "sha256:aa7c30631c5f455d14d561d7b0b414a97652a1b582a301f5570ba2cea2aa9105", size = 50101, upload-time = "2026-01-13T01:09:09.748Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4eaf81d926b65247b3ff9376f71cf3aac287b3d8af1b2a12fd2605fd8534/cloud_sql_python_connector-1.20.1-py3-none-any.whl", hash = "sha256:c00f9d81205eb658fe06f9f353e00646eb3f55d2d86de01dc1222eec1f5f2fc9", size = 50100, upload-time = "2026-03-16T16:53:03.475Z" }, ] [[package]] @@ -570,37 +609,55 @@ wheels = [ [[package]] name = "cryptography" -version = "45.0.6" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/0d/d13399c94234ee8f3df384819dc67e0c5ce215fb751d567a55a1f4b028c7/cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719", size = 744949, upload-time = "2025-08-05T23:59:27.93Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/29/2793d178d0eda1ca4a09a7c4e09a5185e75738cc6d526433e8663b460ea6/cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74", size = 7042702, upload-time = "2025-08-05T23:58:23.464Z" }, - { url = "https://files.pythonhosted.org/packages/b3/b6/cabd07410f222f32c8d55486c464f432808abaa1f12af9afcbe8f2f19030/cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f", size = 4206483, upload-time = "2025-08-05T23:58:27.132Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9e/f9c7d36a38b1cfeb1cc74849aabe9bf817990f7603ff6eb485e0d70e0b27/cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf", size = 4429679, upload-time = "2025-08-05T23:58:29.152Z" }, - { url = "https://files.pythonhosted.org/packages/9c/2a/4434c17eb32ef30b254b9e8b9830cee4e516f08b47fdd291c5b1255b8101/cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5", size = 4210553, upload-time = "2025-08-05T23:58:30.596Z" }, - { url = "https://files.pythonhosted.org/packages/ef/1d/09a5df8e0c4b7970f5d1f3aff1b640df6d4be28a64cae970d56c6cf1c772/cryptography-45.0.6-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2", size = 3894499, upload-time = "2025-08-05T23:58:32.03Z" }, - { url = "https://files.pythonhosted.org/packages/79/62/120842ab20d9150a9d3a6bdc07fe2870384e82f5266d41c53b08a3a96b34/cryptography-45.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08", size = 4458484, upload-time = "2025-08-05T23:58:33.526Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/1bc3634d45ddfed0871bfba52cf8f1ad724761662a0c792b97a951fb1b30/cryptography-45.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402", size = 4210281, upload-time = "2025-08-05T23:58:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fe/ffb12c2d83d0ee625f124880a1f023b5878f79da92e64c37962bbbe35f3f/cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42", size = 4456890, upload-time = "2025-08-05T23:58:36.923Z" }, - { url = "https://files.pythonhosted.org/packages/8c/8e/b3f3fe0dc82c77a0deb5f493b23311e09193f2268b77196ec0f7a36e3f3e/cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05", size = 4333247, upload-time = "2025-08-05T23:58:38.781Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a6/c3ef2ab9e334da27a1d7b56af4a2417d77e7806b2e0f90d6267ce120d2e4/cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453", size = 4565045, upload-time = "2025-08-05T23:58:40.415Z" }, - { url = "https://files.pythonhosted.org/packages/31/c3/77722446b13fa71dddd820a5faab4ce6db49e7e0bf8312ef4192a3f78e2f/cryptography-45.0.6-cp311-abi3-win32.whl", hash = "sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159", size = 2928923, upload-time = "2025-08-05T23:58:41.919Z" }, - { url = "https://files.pythonhosted.org/packages/38/63/a025c3225188a811b82932a4dcc8457a26c3729d81578ccecbcce2cb784e/cryptography-45.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec", size = 3403805, upload-time = "2025-08-05T23:58:43.792Z" }, - { url = "https://files.pythonhosted.org/packages/5b/af/bcfbea93a30809f126d51c074ee0fac5bd9d57d068edf56c2a73abedbea4/cryptography-45.0.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0", size = 7020111, upload-time = "2025-08-05T23:58:45.316Z" }, - { url = "https://files.pythonhosted.org/packages/98/c6/ea5173689e014f1a8470899cd5beeb358e22bb3cf5a876060f9d1ca78af4/cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394", size = 4198169, upload-time = "2025-08-05T23:58:47.121Z" }, - { url = "https://files.pythonhosted.org/packages/ba/73/b12995edc0c7e2311ffb57ebd3b351f6b268fed37d93bfc6f9856e01c473/cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9", size = 4421273, upload-time = "2025-08-05T23:58:48.557Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6e/286894f6f71926bc0da67408c853dd9ba953f662dcb70993a59fd499f111/cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3", size = 4199211, upload-time = "2025-08-05T23:58:50.139Z" }, - { url = "https://files.pythonhosted.org/packages/de/34/a7f55e39b9623c5cb571d77a6a90387fe557908ffc44f6872f26ca8ae270/cryptography-45.0.6-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3", size = 3883732, upload-time = "2025-08-05T23:58:52.253Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b9/c6d32edbcba0cd9f5df90f29ed46a65c4631c4fbe11187feb9169c6ff506/cryptography-45.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301", size = 4450655, upload-time = "2025-08-05T23:58:53.848Z" }, - { url = "https://files.pythonhosted.org/packages/77/2d/09b097adfdee0227cfd4c699b3375a842080f065bab9014248933497c3f9/cryptography-45.0.6-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5", size = 4198956, upload-time = "2025-08-05T23:58:55.209Z" }, - { url = "https://files.pythonhosted.org/packages/55/66/061ec6689207d54effdff535bbdf85cc380d32dd5377173085812565cf38/cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016", size = 4449859, upload-time = "2025-08-05T23:58:56.639Z" }, - { url = "https://files.pythonhosted.org/packages/41/ff/e7d5a2ad2d035e5a2af116e1a3adb4d8fcd0be92a18032917a089c6e5028/cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3", size = 4320254, upload-time = "2025-08-05T23:58:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/82/27/092d311af22095d288f4db89fcaebadfb2f28944f3d790a4cf51fe5ddaeb/cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9", size = 4554815, upload-time = "2025-08-05T23:59:00.283Z" }, - { url = "https://files.pythonhosted.org/packages/7e/01/aa2f4940262d588a8fdf4edabe4cda45854d00ebc6eaac12568b3a491a16/cryptography-45.0.6-cp37-abi3-win32.whl", hash = "sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02", size = 2912147, upload-time = "2025-08-05T23:59:01.716Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459, upload-time = "2025-08-05T23:59:03.358Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] [[package]] @@ -704,7 +761,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.129.0" +version = "0.135.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -713,23 +770,23 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, ] [[package]] name = "fastapi-pagination" -version = "0.15.10" +version = "0.15.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi" }, { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/36/4314836683bec1b33195bbaf2d74e1515cfcbb7e7ef5431ef515b864a5d0/fastapi_pagination-0.15.10.tar.gz", hash = "sha256:0ba7d4f795059a91a9e89358af129f2114876452c1defaf198ea8e3419e9a3cd", size = 575160, upload-time = "2026-02-08T13:13:40.312Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/71/7381bf08f9fb6a890ec41a7ee5191ca564e0af94b899c2006fddaf07d78f/fastapi_pagination-0.15.12.tar.gz", hash = "sha256:914b41e07b8556de34c12d3568c9b7137eb62a3558420061a4acbebf7e729a08", size = 595227, upload-time = "2026-03-28T12:51:03.14Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/95/cce73569317fdba138c315b980c39c6a035baa0ea5867d12276f1d312cff/fastapi_pagination-0.15.10-py3-none-any.whl", hash = "sha256:d50071ebc93b519391f16ff6c3ba9e3603bd659963fe6774ba2f4d5037e17fd8", size = 60798, upload-time = "2026-02-08T13:13:41.972Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2f/644fd77ecac100da965221751ae4f7604e149c58c46c1d96c37e828bb5f7/fastapi_pagination-0.15.12-py3-none-any.whl", hash = "sha256:758e21157b2844feecb2409072f1433e24f2dc9526ae7906aa1a1b28622a970a", size = 60921, upload-time = "2026-03-28T12:51:04.288Z" }, ] [[package]] @@ -847,20 +904,20 @@ wheels = [ [[package]] name = "geoalchemy2" -version = "0.18.1" +version = "0.18.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/df/f6d689120a15a2287794e16696c3bdb4cf2e53038255d288b61a4d59e1fa/geoalchemy2-0.18.1.tar.gz", hash = "sha256:4bdc7daf659e36f6456e2f2c3bcce222b879584921a4f50a803ab05fa2bb3124", size = 239302, upload-time = "2025-11-18T15:12:05.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/9d/02d54b0d61a2f331044ea7b4216ee1c98a8ad3a69906dfc3967c51501623/geoalchemy2-0.18.4.tar.gz", hash = "sha256:5719e2bb040d5c406d5d03425fec87997ce9351843b053ca11373a0f5a31971b", size = 239766, upload-time = "2026-03-02T14:48:48.43Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/25/b3d6fc757d8d909e0e666ec6fbf1b7914e9ad18d6e1b08994cd9d2e63330/geoalchemy2-0.18.1-py3-none-any.whl", hash = "sha256:a49d9559bf7acbb69129a01c6e1861657c15db420886ad0a09b1871fb0ff4bdb", size = 81261, upload-time = "2025-11-18T15:12:03.985Z" }, + { url = "https://files.pythonhosted.org/packages/56/92/60ee150773376aa9b542dcd44660e4ae9f5f14e690c8795be476156de598/geoalchemy2-0.18.4-py3-none-any.whl", hash = "sha256:89e6680dcbb6b8d8c784dcaa889e48ab2783aa42487ee5730fdbd7a7c7ddf6ec", size = 81097, upload-time = "2026-03-02T14:48:47.456Z" }, ] [[package]] name = "google-api-core" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, @@ -869,23 +926,22 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/10/05572d33273292bac49c2d1785925f7bc3ff2fe50e3044cf1062c1dde32e/google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7", size = 177828, upload-time = "2026-01-08T22:21:39.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/b6/85c4d21067220b9a78cfb81f516f9725ea6befc1544ec9bd2c1acd97c324/google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9", size = 173906, upload-time = "2026-01-08T22:21:36.093Z" }, + { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, ] [[package]] name = "google-auth" -version = "2.48.0" +version = "2.49.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, - { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, ] [[package]] @@ -903,7 +959,7 @@ wheels = [ [[package]] name = "google-cloud-storage" -version = "3.9.0" +version = "3.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -913,9 +969,9 @@ dependencies = [ { name = "google-resumable-media" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/b1/4f0798e88285b50dfc60ed3a7de071def538b358db2da468c2e0deecbb40/google_cloud_storage-3.9.0.tar.gz", hash = "sha256:f2d8ca7db2f652be757e92573b2196e10fbc09649b5c016f8b422ad593c641cc", size = 17298544, upload-time = "2026-02-02T13:36:34.119Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/0b/816a6ae3c9fd096937d2e5f9670558908811d57d59ddf69dd4b83b326fd1/google_cloud_storage-3.9.0-py3-none-any.whl", hash = "sha256:2dce75a9e8b3387078cbbdad44757d410ecdb916101f8ba308abf202b6968066", size = 321324, upload-time = "2026-02-02T13:36:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ff/ca9ab2417fa913d75aae38bf40bf856bb2749a604b2e0f701b37cfcd23cc/google_cloud_storage-3.10.1-py3-none-any.whl", hash = "sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f", size = 324453, upload-time = "2026-03-23T09:35:21.368Z" }, ] [[package]] @@ -950,48 +1006,48 @@ wheels = [ [[package]] name = "googleapis-common-protos" -version = "1.72.0" +version = "1.73.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/c0/4a54c386282c13449eca8bbe2ddb518181dc113e78d240458a68856b4d69/googleapis_common_protos-1.73.1.tar.gz", hash = "sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6", size = 147506, upload-time = "2026-03-26T22:17:38.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" }, ] [[package]] name = "greenlet" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, - { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, - { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, - { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, - { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, - { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, - { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, - { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, - { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, - { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, - { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, - { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, - { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, - { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, - { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, - { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] [[package]] @@ -1334,52 +1390,52 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, - { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, - { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, - { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, - { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, - { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, - { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, - { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, - { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, - { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, - { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, - { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, - { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, - { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, - { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, - { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, - { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, - { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, - { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, - { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, - { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, - { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, - { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, - { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, - { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, - { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, ] [[package]] @@ -1503,41 +1559,41 @@ dev = [ requires-dist = [ { name = "aiofiles", specifier = "==24.1.0" }, { name = "aiohappyeyeballs", specifier = "==2.6.1" }, - { name = "aiohttp", specifier = "==3.13.3" }, + { name = "aiohttp", specifier = "==3.13.4" }, { name = "aiosignal", specifier = "==1.4.0" }, { name = "aiosqlite", specifier = "==0.22.1" }, { name = "alembic", specifier = "==1.18.4" }, { name = "annotated-types", specifier = "==0.7.0" }, - { name = "anyio", specifier = "==4.12.1" }, - { name = "apitally", extras = ["fastapi"], specifier = "==0.24.1" }, + { name = "anyio", specifier = "==4.13.0" }, + { name = "apitally", extras = ["fastapi"], specifier = "==0.24.4" }, { name = "asgiref", specifier = "==3.11.1" }, { name = "asn1crypto", specifier = "==1.5.1" }, { name = "asyncpg", specifier = "==0.31.0" }, { name = "attrs", specifier = "==25.4.0" }, - { name = "authlib", specifier = "==1.6.8" }, + { name = "authlib", specifier = "==1.6.9" }, { name = "bcrypt", specifier = "==4.3.0" }, { name = "cachetools", specifier = "==5.5.2" }, { name = "certifi", specifier = "==2025.8.3" }, - { name = "cffi", specifier = "==1.17.1" }, - { name = "charset-normalizer", specifier = "==3.4.4" }, + { name = "cffi", specifier = "==2.0.0" }, + { name = "charset-normalizer", specifier = "==3.4.6" }, { name = "click", specifier = "==8.3.1" }, - { name = "cloud-sql-python-connector", specifier = "==1.20.0" }, - { name = "cryptography", specifier = "==45.0.6" }, + { name = "cloud-sql-python-connector", specifier = "==1.20.1" }, + { name = "cryptography", specifier = "==46.0.7" }, { name = "dnspython", specifier = "==2.8.0" }, { name = "dotenv", specifier = "==0.9.9" }, { name = "email-validator", specifier = "==2.3.0" }, - { name = "fastapi", specifier = "==0.129.0" }, - { name = "fastapi-pagination", specifier = "==0.15.10" }, + { name = "fastapi", specifier = "==0.135.2" }, + { name = "fastapi-pagination", specifier = "==0.15.12" }, { name = "frozenlist", specifier = "==1.8.0" }, - { name = "geoalchemy2", specifier = "==0.18.1" }, - { name = "google-api-core", specifier = "==2.29.0" }, - { name = "google-auth", specifier = "==2.48.0" }, + { name = "geoalchemy2", specifier = "==0.18.4" }, + { name = "google-api-core", specifier = "==2.30.0" }, + { name = "google-auth", specifier = "==2.49.1" }, { name = "google-cloud-core", specifier = "==2.5.0" }, - { name = "google-cloud-storage", specifier = "==3.9.0" }, + { name = "google-cloud-storage", specifier = "==3.10.1" }, { name = "google-crc32c", specifier = "==1.8.0" }, { name = "google-resumable-media", specifier = "==2.8.0" }, - { name = "googleapis-common-protos", specifier = "==1.72.0" }, - { name = "greenlet", specifier = "==3.3.1" }, + { name = "googleapis-common-protos", specifier = "==1.73.1" }, + { name = "greenlet", specifier = "==3.3.2" }, { name = "gunicorn", specifier = "==23.0.0" }, { name = "h11", specifier = "==0.16.0" }, { name = "httpcore", specifier = "==1.0.9" }, @@ -1549,56 +1605,56 @@ requires-dist = [ { name = "mako", specifier = "==1.3.10" }, { name = "markupsafe", specifier = "==3.0.3" }, { name = "multidict", specifier = "==6.7.1" }, - { name = "numpy", specifier = "==2.4.2" }, - { name = "packaging", specifier = "==25.0" }, + { name = "numpy", specifier = "==2.4.4" }, + { name = "packaging", specifier = "==26.0" }, { name = "pandas", specifier = "==2.3.2" }, { name = "pandas-stubs", specifier = "~=2.3.2" }, { name = "pg8000", specifier = "==1.31.5" }, - { name = "phonenumbers", specifier = "==9.0.24" }, - { name = "pillow", specifier = "==11.3.0" }, + { name = "phonenumbers", specifier = "==9.0.26" }, + { name = "pillow", specifier = "==12.1.1" }, { name = "pluggy", specifier = "==1.6.0" }, { name = "pre-commit", specifier = "==4.5.1" }, { name = "propcache", specifier = "==0.4.1" }, - { name = "proto-plus", specifier = "==1.27.1" }, + { name = "proto-plus", specifier = "==1.27.2" }, { name = "protobuf", specifier = "==6.33.5" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, - { name = "pyasn1", specifier = "==0.6.2" }, + { name = "pyasn1", specifier = "==0.6.3" }, { name = "pyasn1-modules", specifier = "==0.4.2" }, - { name = "pycparser", specifier = "==2.23" }, + { name = "pycparser", specifier = "==3.0" }, { name = "pydantic", specifier = "==2.12.5" }, { name = "pydantic-core", specifier = "==2.41.5" }, { name = "pygeoapi", specifier = "==0.22.0" }, - { name = "pygments", specifier = "==2.19.2" }, - { name = "pyjwt", specifier = "==2.11.0" }, + { name = "pygments", specifier = "==2.20.0" }, + { name = "pyjwt", specifier = "==2.12.1" }, { name = "pyproj", specifier = "==3.7.2" }, { name = "pyshp", specifier = "==2.3.1" }, - { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest", specifier = "==9.0.3" }, { name = "pytest-cov", specifier = "==6.2.1" }, { name = "python-dateutil", specifier = "==2.9.0.post0" }, { name = "python-jose", specifier = ">=3.5.0" }, { name = "python-multipart", specifier = "==0.0.22" }, { name = "pytz", specifier = "==2025.2" }, - { name = "requests", specifier = "==2.32.5" }, + { name = "requests", specifier = "==2.33.1" }, { name = "rsa", specifier = "==4.9.1" }, { name = "scramp", specifier = "==1.4.8" }, - { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.53.0" }, + { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.56.0" }, { name = "shapely", specifier = "==2.1.2" }, { name = "six", specifier = "==1.17.0" }, { name = "sniffio", specifier = "==1.3.1" }, - { name = "sqlalchemy", specifier = "==2.0.46" }, + { name = "sqlalchemy", specifier = "==2.0.48" }, { name = "sqlalchemy-continuum", specifier = "==1.6.0" }, { name = "sqlalchemy-searchable", specifier = "==2.1.0" }, { name = "sqlalchemy-utils", specifier = "==0.42.1" }, { name = "starlette", specifier = "==0.52.1" }, { name = "starlette-admin", extras = ["i18n"], specifier = "==0.16.0" }, - { name = "typer", specifier = "==0.23.1" }, + { name = "typer", specifier = "==0.24.1" }, { name = "typing-extensions", specifier = "==4.15.0" }, { name = "typing-inspection", specifier = "==0.4.2" }, { name = "tzdata", specifier = "==2025.3" }, { name = "urllib3", specifier = "==2.6.3" }, { name = "utm", specifier = "==0.8.1" }, - { name = "uvicorn", specifier = "==0.40.0" }, - { name = "yarl", specifier = "==1.22.0" }, + { name = "uvicorn", specifier = "==0.42.0" }, + { name = "yarl", specifier = "==1.23.0" }, ] [package.metadata.requires-dev] @@ -1608,7 +1664,7 @@ dev = [ { name = "faker", specifier = ">=25.0.0" }, { name = "flake8", specifier = ">=7.3.0" }, { name = "pyhamcrest", specifier = ">=2.0.3" }, - { name = "pytest", specifier = ">=8.4.0" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "python-dotenv", specifier = ">=1.1.1" }, { name = "requests", specifier = ">=2.32.5" }, ] @@ -1655,11 +1711,11 @@ wheels = [ [[package]] name = "packaging" -version = "25.0" +version = "26.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" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] @@ -1748,66 +1804,69 @@ wheels = [ [[package]] name = "phonenumbers" -version = "9.0.24" +version = "9.0.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/bf/277ae37edb6f5189937223cc3b2a21b8de9d70ac2d0eb684cf33ba055fdd/phonenumbers-9.0.24.tar.gz", hash = "sha256:97c38e4b5b8af992c75de01bd9c0f84e61701a9c900fd84f49744714910a4dc3", size = 2298138, upload-time = "2026-02-13T11:28:57.724Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/a3/3720326431a23c8e8944a07cdf51520608f1fded87e32e991116fdb801bd/phonenumbers-9.0.26.tar.gz", hash = "sha256:9e582c827f0f5503cddeebef80099475a52ffa761551d8384099c7ec71298cbf", size = 2298587, upload-time = "2026-03-13T11:34:19.656Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/c7/b01beac6077df7261d92c6b52408617690147144d8946f6f6ecb7d9766ab/phonenumbers-9.0.24-py2.py3-none-any.whl", hash = "sha256:fa86ab7112ef8b286a811392311bd76bbbae7d1d271c2ed26cf73f2e9fa4d3c6", size = 2584198, upload-time = "2026-02-13T11:28:55.334Z" }, + { url = "https://files.pythonhosted.org/packages/dd/93/8825b3c9c23e595f34aa11735b29550c27a0f57fe4fc8c9ee737390566ca/phonenumbers-9.0.26-py2.py3-none-any.whl", hash = "sha256:ff473da5712965b6c7f7a31cbff8255864df694eb48243771133ecb761e807c1", size = 2584969, upload-time = "2026-03-13T11:34:16.671Z" }, ] [[package]] name = "pillow" -version = "11.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, - { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, - { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, - { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, - { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, - { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, - { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, - { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, - { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, - { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, - { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, - { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, - { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, - { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, - { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, - { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, - { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, - { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, - { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, - { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, - { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, - { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, - { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, - { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, - { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, ] [[package]] @@ -1915,14 +1974,14 @@ wheels = [ [[package]] name = "proto-plus" -version = "1.27.1" +version = "1.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, + { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" }, ] [[package]] @@ -2000,11 +2059,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.2" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] @@ -2030,11 +2089,11 @@ wheels = [ [[package]] name = "pycparser" -version = "2.23" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] @@ -2172,11 +2231,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -2190,11 +2249,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.11.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [[package]] @@ -2264,7 +2323,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2273,9 +2332,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -2306,11 +2365,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -2514,7 +2573,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2522,9 +2581,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] @@ -2632,15 +2691,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.53.0" +version = "2.56.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/df/5008954f5466085966468612a7d1638487596ee6d2fd7fb51783a85351bf/sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168", size = 426820, upload-time = "2026-03-24T09:56:36.575Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1a/b3a3e9f6520493fed7997af4d2de7965d71549c62f994a8fd15f2ecd519e/sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02", size = 451568, upload-time = "2026-03-24T09:56:34.807Z" }, ] [package.optional-dependencies] @@ -2720,37 +2779,41 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.46" +version = "2.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" }, - { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" }, - { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" }, - { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" }, - { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" }, - { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" }, - { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, - { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, - { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" }, - { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" }, - { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" }, - { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" }, - { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" }, - { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" }, - { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, + { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, + { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, + { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, ] [[package]] @@ -2832,7 +2895,7 @@ wheels = [ [[package]] name = "typer" -version = "0.23.1" +version = "0.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -2840,9 +2903,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] [[package]] @@ -2916,15 +2979,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] [[package]] @@ -2955,80 +3018,88 @@ wheels = [ [[package]] name = "yarl" -version = "1.22.0" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] [[package]]