diff --git a/.env.example b/.env.example index dffd3dfd8..d8a7547d8 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,7 @@ TRANSFER_PARALLEL=1 TRANSFER_WELL_SCREENS=True TRANSFER_SENSORS=True TRANSFER_CONTACTS=True +TRANSFER_PERMISSIONS=True TRANSFER_WATERLEVELS=True TRANSFER_WATERLEVELS_PRESSURE=True TRANSFER_WATERLEVELS_ACOUSTIC=True diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index b9b588eab..1e74a6b35 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -43,6 +43,11 @@ jobs: CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + PYGEOAPI_POSTGRES_DB: "${{ vars.CLOUD_SQL_DATABASE }}" + PYGEOAPI_POSTGRES_USER: "${{ secrets.PYGEOAPI_POSTGRES_USER }}" + PYGEOAPI_POSTGRES_HOST: "${{ vars.PYGEOAPI_POSTGRES_HOST || '127.0.0.1' }}" + PYGEOAPI_POSTGRES_PORT: "${{ vars.PYGEOAPI_POSTGRES_PORT || '5432' }}" + PYGEOAPI_POSTGRES_PASSWORD: "${{ secrets.PYGEOAPI_POSTGRES_PASSWORD }}" CLOUD_SQL_IAM_AUTH: true run: | uv run alembic upgrade head @@ -66,6 +71,11 @@ jobs: CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + PYGEOAPI_POSTGRES_DB: "${{ vars.CLOUD_SQL_DATABASE }}" + PYGEOAPI_POSTGRES_USER: "${{ secrets.PYGEOAPI_POSTGRES_USER }}" + PYGEOAPI_POSTGRES_HOST: "${{ vars.PYGEOAPI_POSTGRES_HOST || '127.0.0.1' }}" + PYGEOAPI_POSTGRES_PORT: "${{ vars.PYGEOAPI_POSTGRES_PORT || '5432' }}" + PYGEOAPI_POSTGRES_PASSWORD: "${{ secrets.PYGEOAPI_POSTGRES_PASSWORD }}" CLOUD_SQL_IAM_AUTH: true GCS_SERVICE_ACCOUNT_KEY: "${{ secrets.GCS_SERVICE_ACCOUNT_KEY }}" GCS_BUCKET_NAME: "${{ vars.GCS_BUCKET_NAME }}" diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index a552dd4f1..2d733cc16 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -43,6 +43,11 @@ jobs: CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + PYGEOAPI_POSTGRES_DB: "${{ vars.CLOUD_SQL_DATABASE }}" + PYGEOAPI_POSTGRES_USER: "${{ secrets.PYGEOAPI_POSTGRES_USER }}" + PYGEOAPI_POSTGRES_HOST: "${{ vars.PYGEOAPI_POSTGRES_HOST || '127.0.0.1' }}" + PYGEOAPI_POSTGRES_PORT: "${{ vars.PYGEOAPI_POSTGRES_PORT || '5432' }}" + PYGEOAPI_POSTGRES_PASSWORD: "${{ secrets.PYGEOAPI_POSTGRES_PASSWORD }}" CLOUD_SQL_IAM_AUTH: true run: | uv run alembic upgrade head @@ -67,6 +72,11 @@ jobs: CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + PYGEOAPI_POSTGRES_DB: "${{ vars.CLOUD_SQL_DATABASE }}" + PYGEOAPI_POSTGRES_USER: "${{ secrets.PYGEOAPI_POSTGRES_USER }}" + PYGEOAPI_POSTGRES_HOST: "${{ vars.PYGEOAPI_POSTGRES_HOST || '127.0.0.1' }}" + PYGEOAPI_POSTGRES_PORT: "${{ vars.PYGEOAPI_POSTGRES_PORT || '5432' }}" + PYGEOAPI_POSTGRES_PASSWORD: "${{ secrets.PYGEOAPI_POSTGRES_PASSWORD }}" CLOUD_SQL_IAM_AUTH: true GCS_SERVICE_ACCOUNT_KEY: "${{ secrets.GCS_SERVICE_ACCOUNT_KEY }}" GCS_BUCKET_NAME: "${{ vars.GCS_BUCKET_NAME }}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a17335c82..f55c668e8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,38 +19,38 @@ jobs: POSTGRES_HOST: localhost POSTGRES_PORT: 5432 POSTGRES_USER: postgres + PYGEOAPI_POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres + PYGEOAPI_POSTGRES_PASSWORD: postgres POSTGRES_DB: ocotilloapi_test + PYGEOAPI_POSTGRES_HOST: localhost + PYGEOAPI_POSTGRES_PORT: 5432 + PYGEOAPI_POSTGRES_DB: ocotilloapi_test DB_DRIVER: postgres BASE_URL: http://localhost:8000 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 - - # These env vars are ONLY for the service container itself - env: - POSTGRES_PASSWORD: postgres - POSTGRES_PORT: 5432 - - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - ports: - # Maps tcp port 5432 on service container to the host - - 5432:5432 - steps: - name: Check out source repository uses: actions/checkout@v6.0.2 + - name: Start database (PostGIS + pg_cron) + run: | + docker compose build db + docker compose up -d db + + - name: Wait for database readiness + run: | + for i in {1..60}; do + if PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c "SELECT 1" >/dev/null 2>&1; then + exit 0 + fi + sleep 2 + done + echo "Database did not become ready in time" + exit 1 + - name: Install uv uses: astral-sh/setup-uv@v7.3.0 with: @@ -76,10 +76,12 @@ jobs: - name: Show Alembic heads run: uv run alembic heads - - name: Create test database + - name: Create test database and extensions run: | - PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -c "CREATE DATABASE ocotilloapi_test" + PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'ocotilloapi_test'" | grep -q 1 || \ + PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -c "CREATE DATABASE ocotilloapi_test" PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d ocotilloapi_test -c "CREATE EXTENSION IF NOT EXISTS postgis" + PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d ocotilloapi_test -c "CREATE EXTENSION IF NOT EXISTS pg_cron" - name: Run tests run: uv run pytest -vv --durations=20 --cov --cov-report=xml --junitxml=junit.xml --ignore=tests/transfers @@ -90,6 +92,10 @@ jobs: report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} + - name: Stop database + if: always() + run: docker compose down -v + bdd-tests: runs-on: ubuntu-latest @@ -98,32 +104,39 @@ jobs: POSTGRES_HOST: localhost POSTGRES_PORT: 5432 POSTGRES_USER: postgres + PYGEOAPI_POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres + PYGEOAPI_POSTGRES_PASSWORD: postgres POSTGRES_DB: ocotilloapi_test + PYGEOAPI_POSTGRES_HOST: localhost + PYGEOAPI_POSTGRES_PORT: 5432 + PYGEOAPI_POSTGRES_DB: ocotilloapi_test DB_DRIVER: postgres BASE_URL: http://localhost:8000 SESSION_SECRET_KEY: supersecretkeyforunittests AUTHENTIK_DISABLE_AUTHENTICATION: 1 DROP_AND_REBUILD_DB: 1 - services: - postgis: - image: postgis/postgis:17-3.5 - 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 + pg_cron) + run: | + docker compose build db + docker compose up -d db + + - name: Wait for database readiness + run: | + for i in {1..60}; do + if PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c "SELECT 1" >/dev/null 2>&1; then + exit 0 + fi + sleep 2 + done + echo "Database did not become ready in time" + exit 1 + - name: Install uv uses: astral-sh/setup-uv@v7.3.0 with: @@ -149,10 +162,16 @@ jobs: - name: Show Alembic heads run: uv run alembic heads - - name: Create test database + - name: Create test database and extensions run: | - PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -c "CREATE DATABASE ocotilloapi_test" + PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'ocotilloapi_test'" | grep -q 1 || \ + PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -c "CREATE DATABASE ocotilloapi_test" PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d ocotilloapi_test -c "CREATE EXTENSION IF NOT EXISTS postgis" + PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d ocotilloapi_test -c "CREATE EXTENSION IF NOT EXISTS pg_cron" - 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/.gitignore b/.gitignore index 9d9c353ec..327f4edbf 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,6 @@ run_bdd-local.sh .pre-commit-config.local.yaml .serena/ cli/logs - +.pygeoapi/ # deployment files app.yaml diff --git a/CLAUDE.md b/CLAUDE.md index 6eb6f2937..e44660d71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -237,6 +237,6 @@ GitHub Actions workflows (`.github/workflows/`): ## Additional Resources - **API Docs**: `http://localhost:8000/docs` (Swagger UI) or `/redoc` (ReDoc) -- **OGC API**: `http://localhost:8000/ogc` for OGC API - Features endpoints +- **OGC API**: `http://localhost:8000/ogcapi` for OGC API - Features endpoints - **CLI**: `oco --help` for Ocotillo CLI commands - **Sentry**: Error tracking and performance monitoring integrated diff --git a/README.md b/README.md index 82be22219..155dc2b94 100644 --- a/README.md +++ b/README.md @@ -27,31 +27,31 @@ supports research, field operations, and public data delivery for the Bureau of ## 🗺️ OGC API - Features -The API exposes OGC API - Features endpoints under `/ogc`. +The API exposes OGC API - Features endpoints under `/ogcapi` using `pygeoapi`. ### Landing & metadata ```bash -curl http://localhost:8000/ogc -curl http://localhost:8000/ogc/conformance -curl http://localhost:8000/ogc/collections -curl http://localhost:8000/ogc/collections/locations +curl http://localhost:8000/ogcapi +curl http://localhost:8000/ogcapi/conformance +curl http://localhost:8000/ogcapi/collections +curl http://localhost:8000/ogcapi/collections/locations ``` ### Items (GeoJSON) ```bash -curl "http://localhost:8000/ogc/collections/locations/items?limit=10&offset=0" -curl "http://localhost:8000/ogc/collections/wells/items?limit=5" -curl "http://localhost:8000/ogc/collections/springs/items?limit=5" -curl "http://localhost:8000/ogc/collections/locations/items/123" +curl "http://localhost:8000/ogcapi/collections/locations/items?limit=10&offset=0" +curl "http://localhost:8000/ogcapi/collections/wells/items?limit=5" +curl "http://localhost:8000/ogcapi/collections/springs/items?limit=5" +curl "http://localhost:8000/ogcapi/collections/locations/items/123" ``` ### BBOX + datetime filters ```bash -curl "http://localhost:8000/ogc/collections/locations/items?bbox=-107.9,33.8,-107.8,33.9" -curl "http://localhost:8000/ogc/collections/wells/items?datetime=2020-01-01/2024-01-01" +curl "http://localhost:8000/ogcapi/collections/locations/items?bbox=-107.9,33.8,-107.8,33.9" +curl "http://localhost:8000/ogcapi/collections/wells/items?datetime=2020-01-01/2024-01-01" ``` ### Polygon filter (CQL2 text) @@ -59,18 +59,13 @@ curl "http://localhost:8000/ogc/collections/wells/items?datetime=2020-01-01/2024 Use `filter` + `filter-lang=cql2-text` with `WITHIN(...)`: ```bash -curl "http://localhost:8000/ogc/collections/locations/items?filter=WITHIN(geometry,POLYGON((-107.9 33.8,-107.8 33.8,-107.8 33.9,-107.9 33.9,-107.9 33.8)))&filter-lang=cql2-text" +curl "http://localhost:8000/ogcapi/collections/locations/items?filter=WITHIN(geometry,POLYGON((-107.9 33.8,-107.8 33.8,-107.8 33.9,-107.9 33.9,-107.9 33.8)))&filter-lang=cql2-text" ``` -### Property filter (CQL) - -Basic property filters are supported with `properties`: +### OpenAPI UI ```bash -curl "http://localhost:8000/ogc/collections/wells/items?properties=thing_type='water well' AND well_depth>=100 AND well_depth<=200" -curl "http://localhost:8000/ogc/collections/wells/items?properties=well_purposes IN ('domestic','irrigation')" -curl "http://localhost:8000/ogc/collections/wells/items?properties=well_casing_materials='PVC'" -curl "http://localhost:8000/ogc/collections/wells/items?properties=well_screen_type='Steel'" +curl "http://localhost:8000/ogcapi/openapi?ui=swagger" ``` diff --git a/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py b/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py new file mode 100644 index 000000000..e76c6aa64 --- /dev/null +++ b/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py @@ -0,0 +1,369 @@ +"""Create pygeoapi supporting OGC views. + +Revision ID: d5e6f7a8b9c0 +Revises: c4d5e6f7a8b9 +Create Date: 2026-02-25 12:00:00.000000 +""" + +import re +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "d5e6f7a8b9c0" +down_revision: Union[str, Sequence[str], None] = "c4d5e6f7a8b9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None +REFRESH_FUNCTION_NAME = "refresh_pygeoapi_materialized_views" +REFRESH_JOB_NAME = "refresh_pygeoapi_matviews_nightly" +REFRESH_SCHEDULE = "0 3 * * *" + +THING_COLLECTIONS = [ + ("water_wells", "water well"), + ("springs", "spring"), + ("abandoned_wells", "abandoned well"), + ("artesian_wells", "artesian well"), + ("diversions_surface_water", "diversion of surface water, etc."), + ("dry_holes", "dry hole"), + ("dug_wells", "dug well"), + ("ephemeral_streams", "ephemeral stream"), + ("exploration_wells", "exploration well"), + ("injection_wells", "injection well"), + ("lakes_ponds_reservoirs", "lake, pond or reservoir"), + ("meteorological_stations", "meteorological station"), + ("monitoring_wells", "monitoring well"), + ("observation_wells", "observation well"), + ("other_things", "other"), + ("outfalls_wastewater_return_flow", "outfall of wastewater or return flow"), + ("perennial_streams", "perennial stream"), + ("piezometers", "piezometer"), + ("production_wells", "production well"), + ("rock_sample_locations", "rock sample location"), + ("soil_gas_sample_locations", "soil gas sample location"), + ("test_wells", "test well"), +] + + +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 _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 ( + 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 + ) + SELECT + t.id, + t.name, + t.thing_type, + t.first_visit_date, + t.spring_type, + 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.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 _create_latest_depth_view() -> str: + return """ + CREATE MATERIALIZED VIEW ogc_latest_depth_to_water_wells AS + WITH latest_location AS ( + 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 + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + 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, + 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 + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + ro.observation_id, + ro.observation_datetime, + ro.value AS depth_to_water_reference, + ro.measuring_point_height, + ro.depth_to_water_bgs, + 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 _create_avg_tds_view() -> str: + return """ + CREATE MATERIALIZED VIEW ogc_avg_tds_wells AS + WITH latest_location AS ( + 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 + ), + tds_obs AS ( + SELECT + csi.thing_id, + mc.id AS major_chemistry_id, + mc."AnalysisDate" AS analysis_date, + mc."SampleValue" 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 + t.thing_type = 'water well' + AND mc."SampleValue" IS NOT NULL + AND ( + lower(coalesce(mc."Analyte", '')) IN ( + 'tds', + 'total dissolved solids' + ) + OR lower(coalesce(mc."Symbol", '')) = 'tds' + ) + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + COUNT(to2.major_chemistry_id)::integer AS tds_observation_count, + AVG(to2.sample_value)::double precision AS avg_tds_value, + MIN(to2.analysis_date) AS first_tds_observation_datetime, + MAX(to2.analysis_date) AS latest_tds_observation_datetime, + l.point + FROM tds_obs AS to2 + JOIN thing AS t ON t.id = to2.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + GROUP BY t.id, t.name, t.thing_type, l.point + """ + + +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_matview_indexes() -> None: + # Required so REFRESH MATERIALIZED VIEW CONCURRENTLY can run. + 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)") + ) + + +def _create_refresh_function() -> str: + return f""" + CREATE OR REPLACE FUNCTION public.{REFRESH_FUNCTION_NAME}() + RETURNS void + LANGUAGE plpgsql + AS $$ + DECLARE + matview_record record; + matview_fqname text; + BEGIN + FOR matview_record IN + SELECT schemaname, matviewname + FROM pg_matviews + WHERE schemaname = 'public' + AND matviewname LIKE 'ogc_%' + LOOP + matview_fqname := format('%I.%I', matview_record.schemaname, matview_record.matviewname); + EXECUTE format('REFRESH MATERIALIZED VIEW %s', matview_fqname); + END LOOP; + END; + $$; + """ + + +def _schedule_refresh_job() -> str: + return f""" + DO $do$ + DECLARE + existing_job_id bigint; + BEGIN + SELECT jobid INTO existing_job_id + FROM cron.job + WHERE jobname = '{REFRESH_JOB_NAME}'; + + IF existing_job_id IS NOT NULL THEN + PERFORM cron.unschedule(existing_job_id); + END IF; + + PERFORM cron.schedule( + '{REFRESH_JOB_NAME}', + '{REFRESH_SCHEDULE}', + $cmd$SELECT public.{REFRESH_FUNCTION_NAME}();$cmd$ + ); + END + $do$; + """ + + +def _unschedule_refresh_job() -> str: + return f""" + DO $do$ + DECLARE + existing_job_id bigint; + BEGIN + IF to_regclass('cron.job') IS NULL THEN + RETURN; + END IF; + + SELECT jobid INTO existing_job_id + FROM cron.job + WHERE jobname = '{REFRESH_JOB_NAME}'; + + IF existing_job_id IS NOT NULL THEN + PERFORM cron.unschedule(existing_job_id); + END IF; + END + $do$; + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + 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_str = ", ".join(missing_tables) + raise RuntimeError( + "Cannot create pygeoapi supporting views. The following required core " + f"tables are missing: {missing_tables_str}" + ) + + pg_cron_available = bind.execute( + text( + "SELECT EXISTS (" + "SELECT 1 FROM pg_available_extensions WHERE name = 'pg_cron'" + ")" + ) + ).scalar() + if not pg_cron_available: + raise RuntimeError( + "Cannot schedule nightly pygeoapi materialized view refresh job: " + "pg_cron extension is not available on this PostgreSQL server." + ) + op.execute(text("CREATE EXTENSION IF NOT EXISTS pg_cron")) + + for view_id, thing_type in THING_COLLECTIONS: + safe_view_id = _safe_view_id(view_id) + op.execute(text(f"DROP VIEW IF EXISTS ogc_{safe_view_id}")) + op.execute(text(_create_thing_view(view_id, thing_type))) + + _drop_view_or_materialized_view("ogc_latest_depth_to_water_wells") + required_depth = {"observation", "sample", "field_activity", "field_event"} + if not required_depth.issubset(existing_tables): + missing_depth_tables = sorted( + t for t in required_depth if t not in existing_tables + ) + missing_depth_tables_str = ", ".join(missing_depth_tables) + raise RuntimeError( + "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())) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_latest_depth_to_water_wells IS " + "'Latest depth-to-water per well view for pygeoapi.'" + ) + ) + + _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_str = ", ".join(missing_tds_tables) + raise RuntimeError( + "Cannot create ogc_avg_tds_wells. The following required " + f"tables are missing: {missing_tds_tables_str}" + ) + op.execute(text(_create_avg_tds_view())) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_avg_tds_wells IS " + "'Average TDS per well from major chemistry results for pygeoapi.'" + ) + ) + _create_matview_indexes() + + op.execute(text(_create_refresh_function())) + op.execute(text(_schedule_refresh_job())) + + +def downgrade() -> None: + op.execute(text(_unschedule_refresh_job())) + op.execute(text(f"DROP FUNCTION IF EXISTS public.{REFRESH_FUNCTION_NAME}()")) + _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: + safe_view_id = _safe_view_id(view_id) + op.execute(text(f"DROP VIEW IF EXISTS ogc_{safe_view_id}")) diff --git a/api/README.md b/api/README.md index fd6767de7..143413cc7 100644 --- a/api/README.md +++ b/api/README.md @@ -5,7 +5,7 @@ This directory contains FastAPI route modules grouped by resource/domain. ## Structure - One module per domain (for example `thing.py`, `contact.py`, `observation.py`) -- `api/ogc/` contains OGC-specific endpoints +- OGC API - Features is mounted via `pygeoapi` (see `core/pygeoapi.py`) ## Guidelines diff --git a/api/ogc/__init__.py b/api/ogc/__init__.py deleted file mode 100644 index a03d84c6a..000000000 --- a/api/ogc/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# ============= OGC API package ============================================= diff --git a/api/ogc/collections.py b/api/ogc/collections.py deleted file mode 100644 index 3ee9880cc..000000000 --- a/api/ogc/collections.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from typing import Dict - -from fastapi import Request - -from api.ogc.schemas import Collection, CollectionExtent, CollectionExtentSpatial, Link - -BASE_CRS = "http://www.opengis.net/def/crs/OGC/1.3/CRS84" - - -COLLECTIONS: Dict[str, dict] = { - "locations": { - "title": "Locations", - "description": "Sample locations", - "itemType": "feature", - }, - "wells": { - "title": "Wells", - "description": "Things filtered to water wells", - "itemType": "feature", - }, - "springs": { - "title": "Springs", - "description": "Things filtered to springs", - "itemType": "feature", - }, -} - - -def _collection_links(request: Request, collection_id: str) -> list[Link]: - base = str(request.base_url).rstrip("/") - return [ - Link( - href=f"{base}/ogc/collections/{collection_id}", - rel="self", - type="application/json", - ), - Link( - href=f"{base}/ogc/collections/{collection_id}/items", - rel="items", - type="application/geo+json", - ), - Link( - href=f"{base}/ogc/collections", - rel="collection", - type="application/json", - ), - ] - - -def list_collections(request: Request) -> list[Collection]: - collections = [] - for cid, meta in COLLECTIONS.items(): - extent = CollectionExtent( - spatial=CollectionExtentSpatial( - bbox=[[-180.0, -90.0, 180.0, 90.0]], crs=BASE_CRS - ) - ) - collections.append( - Collection( - id=cid, - title=meta["title"], - description=meta.get("description"), - itemType=meta.get("itemType", "feature"), - crs=[BASE_CRS], - links=_collection_links(request, cid), - extent=extent, - ) - ) - return collections - - -def get_collection(request: Request, collection_id: str) -> Collection | None: - meta = COLLECTIONS.get(collection_id) - if not meta: - return None - extent = CollectionExtent( - spatial=CollectionExtentSpatial( - bbox=[[-180.0, -90.0, 180.0, 90.0]], crs=BASE_CRS - ) - ) - return Collection( - id=collection_id, - title=meta["title"], - description=meta.get("description"), - itemType=meta.get("itemType", "feature"), - crs=[BASE_CRS], - links=_collection_links(request, collection_id), - extent=extent, - ) diff --git a/api/ogc/conformance.py b/api/ogc/conformance.py deleted file mode 100644 index c02872caa..000000000 --- a/api/ogc/conformance.py +++ /dev/null @@ -1,8 +0,0 @@ -CONFORMANCE_CLASSES = [ - "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core", - "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30", - "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson", - "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/collections", - "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/features", - "http://www.opengis.net/spec/cql2/1.0/conf/cql2-text", -] diff --git a/api/ogc/features.py b/api/ogc/features.py deleted file mode 100644 index 47a1024e5..000000000 --- a/api/ogc/features.py +++ /dev/null @@ -1,473 +0,0 @@ -from __future__ import annotations - -from datetime import date, datetime, timezone -import re -from typing import Any, Dict, Tuple - -from fastapi import HTTPException, Request -from geoalchemy2.functions import ( - ST_AsGeoJSON, - ST_GeomFromText, - ST_Intersects, - ST_MakeEnvelope, - ST_Within, -) -from sqlalchemy import exists, func, select -from sqlalchemy.orm import aliased, selectinload - -from core.constants import SRID_WGS84 -from db.location import Location, LocationThingAssociation -from db.thing import Thing, WellCasingMaterial, WellPurpose, WellScreen - - -def _parse_bbox(bbox: str) -> Tuple[float, float, float, float]: - try: - parts = [float(part) for part in bbox.split(",")] - except ValueError as exc: - raise HTTPException(status_code=400, detail="Invalid bbox format") from exc - if len(parts) not in (4, 6): - raise HTTPException(status_code=400, detail="bbox must have 4 or 6 values") - return parts[0], parts[1], parts[2], parts[3] - - -def _parse_datetime(value: str) -> datetime: - text = value.strip() - if text.endswith("Z"): - text = text[:-1] + "+00:00" - parsed = datetime.fromisoformat(text) - if parsed.tzinfo is None: - return parsed.replace(tzinfo=timezone.utc) - return parsed - - -def _parse_datetime_range(value: str) -> Tuple[datetime | None, datetime | None]: - if "/" in value: - start_text, end_text = value.split("/", 1) - start = _parse_datetime(start_text) if start_text else None - end = _parse_datetime(end_text) if end_text else None - return start, end - single = _parse_datetime(value) - return single, single - - -def _coerce_value(value: str) -> Any: - stripped = value.strip() - if stripped.startswith("'") and stripped.endswith("'"): - return stripped[1:-1] - if stripped.startswith('"') and stripped.endswith('"'): - return stripped[1:-1] - try: - if "." in stripped: - return float(stripped) - return int(stripped) - except ValueError: - return stripped - - -def _split_and_clauses(properties: str) -> list[str]: - lower = properties.lower() - clauses = [] - buffer = [] - in_single_quote = False - in_double_quote = False - idx = 0 - while idx < len(properties): - char = properties[idx] - if char == "'" and not in_double_quote: - in_single_quote = not in_single_quote - buffer.append(char) - idx += 1 - continue - if char == '"' and not in_single_quote: - in_double_quote = not in_double_quote - buffer.append(char) - idx += 1 - continue - if not in_single_quote and not in_double_quote: - if lower[idx : idx + 3] == "and": - before = properties[idx - 1] if idx > 0 else " " - after = properties[idx + 3] if idx + 3 < len(properties) else " " - if before.isspace() and after.isspace(): - clause = "".join(buffer).strip() - if clause: - clauses.append(clause) - buffer = [] - idx += 3 - continue - buffer.append(char) - idx += 1 - clause = "".join(buffer).strip() - if clause: - clauses.append(clause) - return clauses - - -def _split_field_and_value(text: str) -> tuple[str | None, str | None]: - left, sep, right = text.partition("=") - if not sep: - return None, None - field = left.strip() - value = right.strip() - if not field or not value: - return None, None - return field, value - - -def _apply_properties_filter( - query, - properties: str, - column_map: Dict[str, Any], - relationship_map: Dict[str, Any] | None = None, -): - relationship_map = relationship_map or {} - clauses = _split_and_clauses(properties) - for clause in clauses: - in_match = re.match( - r"^\s*(\w+)\s+IN\s+\((.+)\)\s*$", clause, flags=re.IGNORECASE - ) - if in_match: - field = in_match.group(1) - values = [val.strip() for val in in_match.group(2).split(",")] - if field in relationship_map: - query = query.where( - relationship_map[field]([_coerce_value(v) for v in values]) - ) - continue - if field not in column_map: - raise HTTPException( - status_code=400, detail=f"Unsupported property: {field}" - ) - query = query.where( - column_map[field].in_([_coerce_value(v) for v in values]) - ) - continue - field, value = _split_field_and_value(clause) - if field and value: - if field in relationship_map: - query = query.where(relationship_map[field]([_coerce_value(value)])) - continue - if field not in column_map: - raise HTTPException( - status_code=400, detail=f"Unsupported property: {field}" - ) - query = query.where(column_map[field] == _coerce_value(value)) - continue - raise HTTPException( - status_code=400, detail=f"Unsupported CQL expression: {clause}" - ) - return query - - -def _apply_cql_filter(query, filter_expr: str): - match = re.match( - r"^\s*(INTERSECTS|WITHIN)\s*\(\s*(geometry|geom)\s*,\s*(POLYGON|MULTIPOLYGON)\s*(\(.+\))\s*\)\s*$", - filter_expr, - flags=re.IGNORECASE | re.DOTALL, - ) - if not match: - raise HTTPException(status_code=400, detail="Unsupported CQL filter expression") - op = match.group(1).upper() - wkt = f"{match.group(3).upper()} {match.group(4)}" - geom = ST_GeomFromText(wkt, SRID_WGS84) - if op == "WITHIN": - return query.where(ST_Within(Location.point, geom)) - return query.where(ST_Intersects(Location.point, geom)) - - -def _latest_location_subquery(): - return ( - select( - LocationThingAssociation.thing_id, - func.max(LocationThingAssociation.effective_start).label("max_start"), - ) - .where(LocationThingAssociation.effective_end.is_(None)) - .group_by(LocationThingAssociation.thing_id) - .subquery() - ) - - -def _location_query(): - return select( - Location, - ST_AsGeoJSON(Location.point).label("geojson"), - ) - - -def _thing_query(thing_type: str, eager_well_relationships: bool = False): - lta_alias = aliased(LocationThingAssociation) - latest_assoc = _latest_location_subquery() - query = ( - select( - Thing, - ST_AsGeoJSON(Location.point).label("geojson"), - ) - .join(lta_alias, Thing.id == lta_alias.thing_id) - .join(Location, lta_alias.location_id == Location.id) - .join( - latest_assoc, - (latest_assoc.c.thing_id == lta_alias.thing_id) - & (latest_assoc.c.max_start == lta_alias.effective_start), - ) - .where(Thing.thing_type == thing_type) - ) - if eager_well_relationships: - query = query.options( - selectinload(Thing.well_purposes), - selectinload(Thing.well_casing_materials), - selectinload(Thing.screens), - ) - return query - - -def _apply_bbox_filter(query, bbox: str): - minx, miny, maxx, maxy = _parse_bbox(bbox) - envelope = ST_MakeEnvelope(minx, miny, maxx, maxy, SRID_WGS84) - return query.where(ST_Intersects(Location.point, envelope)) - - -def _apply_datetime_filter(query, datetime_value: str, column): - start, end = _parse_datetime_range(datetime_value) - if start is not None: - query = query.where(column >= start) - if end is not None: - query = query.where(column <= end) - return query - - -def _build_feature(row, collection_id: str) -> dict[str, Any]: - model, geojson = row - geometry = {} if geojson is None else _safe_json(geojson) - if collection_id == "locations": - properties = { - "id": model.id, - "description": model.description, - "county": model.county, - "state": model.state, - "quad_name": model.quad_name, - "elevation": model.elevation, - } - else: - properties = { - "id": model.id, - "name": model.name, - "thing_type": model.thing_type, - "first_visit_date": model.first_visit_date, - "nma_pk_welldata": model.nma_pk_welldata, - "well_depth": model.well_depth, - "hole_depth": model.hole_depth, - "well_casing_diameter": model.well_casing_diameter, - "well_casing_depth": model.well_casing_depth, - "well_completion_date": model.well_completion_date, - "well_driller_name": model.well_driller_name, - "well_construction_method": model.well_construction_method, - "well_pump_type": model.well_pump_type, - "well_pump_depth": model.well_pump_depth, - "formation_completion_code": model.formation_completion_code, - } - if collection_id == "wells": - properties["well_purposes"] = [ - purpose.purpose for purpose in (model.well_purposes or []) - ] - properties["well_casing_materials"] = [ - casing.material for casing in (model.well_casing_materials or []) - ] - properties["well_screens"] = [ - { - "screen_depth_top": screen.screen_depth_top, - "screen_depth_bottom": screen.screen_depth_bottom, - "screen_type": screen.screen_type, - "screen_description": screen.screen_description, - } - for screen in (model.screens or []) - ] - properties["open_status"] = model.open_status - properties["datalogger_suitability_status"] = ( - model.datalogger_suitability_status - ) - if hasattr(model, "nma_formation_zone"): - properties["nma_formation_zone"] = model.nma_formation_zone - return { - "type": "Feature", - "id": model.id, - "geometry": geometry, - "properties": _json_ready(properties), - } - - -def _safe_json(value: str) -> dict[str, Any]: - try: - return __import__("json").loads(value) - except Exception: - return {} - - -def _json_ready(value: Any) -> Any: - if isinstance(value, (datetime, date)): - return value.isoformat() - if isinstance(value, dict): - return {key: _json_ready(val) for key, val in value.items()} - if isinstance(value, (list, tuple)): - return [_json_ready(val) for val in value] - return value - - -def get_items( - request: Request, - session, - collection_id: str, - bbox: str | None, - datetime_value: str | None, - limit: int, - offset: int, - properties: str | None, - filter_expr: str | None, - filter_lang: str | None, -) -> dict[str, Any]: - if collection_id == "locations": - query = _location_query() - column_map = { - "id": Location.id, - "description": Location.description, - "county": Location.county, - "state": Location.state, - "quad_name": Location.quad_name, - "release_status": Location.release_status, - } - datetime_column = Location.created_at - relationship_map = {} - elif collection_id == "wells": - query = _thing_query("water well", eager_well_relationships=True) - column_map = { - "id": Thing.id, - "name": Thing.name, - "thing_type": Thing.thing_type, - "first_visit_date": Thing.first_visit_date, - "nma_pk_welldata": Thing.nma_pk_welldata, - "well_depth": Thing.well_depth, - "hole_depth": Thing.hole_depth, - "well_casing_diameter": Thing.well_casing_diameter, - "well_casing_depth": Thing.well_casing_depth, - "well_completion_date": Thing.well_completion_date, - "well_driller_name": Thing.well_driller_name, - "well_construction_method": Thing.well_construction_method, - "well_pump_type": Thing.well_pump_type, - "well_pump_depth": Thing.well_pump_depth, - "formation_completion_code": Thing.formation_completion_code, - "well_status": Thing.well_status, - "open_status": Thing.open_status, - "datalogger_suitability_status": Thing.datalogger_suitability_status, - } - if hasattr(Thing, "nma_formation_zone"): - column_map["nma_formation_zone"] = Thing.nma_formation_zone - datetime_column = Thing.created_at - relationship_map = { - "well_purposes": lambda values: exists( - select(1).where( - WellPurpose.thing_id == Thing.id, - WellPurpose.purpose.in_(values), - ) - ), - "well_casing_materials": lambda values: exists( - select(1).where( - WellCasingMaterial.thing_id == Thing.id, - WellCasingMaterial.material.in_(values), - ) - ), - "well_screen_type": lambda values: exists( - select(1).where( - WellScreen.thing_id == Thing.id, - WellScreen.screen_type.in_(values), - ) - ), - } - elif collection_id == "springs": - query = _thing_query("spring") - column_map = { - "id": Thing.id, - "name": Thing.name, - "thing_type": Thing.thing_type, - "nma_pk_welldata": Thing.nma_pk_welldata, - } - datetime_column = Thing.created_at - relationship_map = {} - else: - raise HTTPException(status_code=404, detail="Collection not found") - - if bbox: - query = _apply_bbox_filter(query, bbox) - if datetime_value: - query = _apply_datetime_filter(query, datetime_value, datetime_column) - if properties: - query = _apply_properties_filter( - query, properties, column_map, relationship_map - ) - if filter_expr: - if filter_lang and filter_lang.lower() != "cql2-text": - raise HTTPException(status_code=400, detail="Unsupported filter-lang") - query = _apply_cql_filter(query, filter_expr) - - total = session.execute( - select(func.count()).select_from(query.subquery()) - ).scalar_one() - rows = session.execute(query.limit(limit).offset(offset)).all() - features = [_build_feature(row, collection_id) for row in rows] - - base = str(request.base_url).rstrip("/") - links = [ - { - "href": f"{base}/ogc/collections/{collection_id}/items?limit={limit}&offset={offset}", - "rel": "self", - "type": "application/geo+json", - }, - { - "href": f"{base}/ogc/collections/{collection_id}", - "rel": "collection", - "type": "application/json", - }, - ] - - return { - "type": "FeatureCollection", - "features": features, - "links": links, - "numberMatched": total, - "numberReturned": len(features), - } - - -def get_item( - request: Request, - session, - collection_id: str, - fid: int, -) -> dict[str, Any]: - if collection_id == "locations": - query = _location_query().where(Location.id == fid) - elif collection_id == "wells": - query = _thing_query("water well", eager_well_relationships=True).where( - Thing.id == fid - ) - elif collection_id == "springs": - query = _thing_query("spring").where(Thing.id == fid) - else: - raise HTTPException(status_code=404, detail="Collection not found") - - row = session.execute(query).first() - if row is None: - raise HTTPException(status_code=404, detail="Feature not found") - - feature = _build_feature(row, collection_id) - base = str(request.base_url).rstrip("/") - feature["links"] = [ - { - "href": f"{base}/ogc/collections/{collection_id}/items/{fid}", - "rel": "self", - "type": "application/geo+json", - }, - { - "href": f"{base}/ogc/collections/{collection_id}", - "rel": "collection", - "type": "application/json", - }, - ] - return feature diff --git a/api/ogc/router.py b/api/ogc/router.py deleted file mode 100644 index bfaa36c65..000000000 --- a/api/ogc/router.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from typing import Annotated - -from fastapi import APIRouter, Query, Request -from starlette.responses import JSONResponse - -from api.ogc.collections import get_collection, list_collections -from api.ogc.conformance import CONFORMANCE_CLASSES -from api.ogc.features import get_item, get_items -from api.ogc.schemas import Conformance, LandingPage -from core.dependencies import session_dependency, viewer_dependency - -router = APIRouter(prefix="/ogc", tags=["ogc"]) - - -@router.get("/") -def landing_page(request: Request) -> LandingPage: - base = str(request.base_url).rstrip("/") - return { - "title": "Ocotillo OGC API", - "description": "OGC API - Features endpoints", - "links": [ - { - "href": f"{base}/ogc", - "rel": "self", - "type": "application/json", - }, - { - "href": f"{base}/ogc/conformance", - "rel": "conformance", - "type": "application/json", - }, - { - "href": f"{base}/ogc/collections", - "rel": "data", - "type": "application/json", - }, - ], - } - - -@router.get("/conformance") -def conformance() -> Conformance: - return {"conformsTo": CONFORMANCE_CLASSES} - - -@router.get("/collections") -def collections(request: Request) -> JSONResponse: - base = str(request.base_url).rstrip("/") - payload = { - "links": [ - { - "href": f"{base}/ogc/collections", - "rel": "self", - "type": "application/json", - } - ], - "collections": [c.model_dump() for c in list_collections(request)], - } - return JSONResponse(content=payload, media_type="application/json") - - -@router.get("/collections/{collection_id}") -def collection(request: Request, collection_id: str) -> JSONResponse: - record = get_collection(request, collection_id) - if record is None: - return JSONResponse(status_code=404, content={"detail": "Collection not found"}) - return JSONResponse(content=record.model_dump(), media_type="application/json") - - -@router.get("/collections/{collection_id}/items") -def items( - request: Request, - user: viewer_dependency, - session: session_dependency, - collection_id: str, - bbox: Annotated[str | None, Query(description="minx,miny,maxx,maxy")] = None, - datetime: Annotated[str | None, Query(alias="datetime")] = None, - limit: Annotated[int, Query(ge=1, le=1000)] = 100, - offset: Annotated[int, Query(ge=0)] = 0, - properties: Annotated[str | None, Query(description="CQL filter")] = None, - filter_: Annotated[str | None, Query(alias="filter")] = None, - filter_lang: Annotated[str | None, Query(alias="filter-lang")] = None, -): - payload = get_items( - request, - session, - collection_id, - bbox, - datetime, - limit, - offset, - properties, - filter_, - filter_lang, - ) - return JSONResponse(content=payload, media_type="application/geo+json") - - -@router.get("/collections/{collection_id}/items/{fid}") -def item( - request: Request, - user: viewer_dependency, - session: session_dependency, - collection_id: str, - fid: int, -): - payload = get_item(request, session, collection_id, fid) - return JSONResponse(content=payload, media_type="application/geo+json") diff --git a/api/ogc/schemas.py b/api/ogc/schemas.py deleted file mode 100644 index ed87e183f..000000000 --- a/api/ogc/schemas.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional - -from pydantic import BaseModel, Field - - -class Link(BaseModel): - href: str - rel: str - type: Optional[str] = None - title: Optional[str] = None - - -class LandingPage(BaseModel): - title: str - description: str - links: List[Link] - - -class Conformance(BaseModel): - conformsTo: List[str] = Field(default_factory=list) - - -class CollectionExtentSpatial(BaseModel): - bbox: List[List[float]] - crs: str - - -class CollectionExtentTemporal(BaseModel): - interval: List[List[Optional[str]]] - trs: Optional[str] = None - - -class CollectionExtent(BaseModel): - spatial: Optional[CollectionExtentSpatial] = None - temporal: Optional[CollectionExtentTemporal] = None - - -class Collection(BaseModel): - id: str - title: str - description: Optional[str] = None - itemType: str = "feature" - crs: Optional[List[str]] = None - links: List[Link] - extent: Optional[CollectionExtent] = None - - -class Collections(BaseModel): - links: List[Link] - collections: List[Collection] - - -class Feature(BaseModel): - type: str = "Feature" - id: str | int - geometry: dict[str, Any] - properties: dict[str, Any] - - -class FeatureCollection(BaseModel): - type: str = "FeatureCollection" - features: List[Feature] - links: List[Link] - numberMatched: int - numberReturned: int diff --git a/cli/cli.py b/cli/cli.py index ae54ab42d..19b34cc9a 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -24,7 +24,8 @@ import typer from dotenv import load_dotenv -load_dotenv() +# CLI should honor local `.env` values, even if shell/container vars already exist. +load_dotenv(override=True) os.environ.setdefault("OCO_LOG_CONTEXT", "cli") cli = typer.Typer(help="Command line interface for managing the application.") @@ -49,6 +50,12 @@ class SmokePopulation(str, Enum): agreed = "agreed" +PYGEOAPI_MATERIALIZED_VIEWS = ( + "ogc_latest_depth_to_water_wells", + "ogc_avg_tds_wells", +) + + def _resolve_theme(theme: ThemeMode) -> ThemeMode: if theme != ThemeMode.auto: return theme @@ -68,6 +75,12 @@ def _resolve_theme(theme: ThemeMode) -> ThemeMode: return ThemeMode.dark +def _validate_sql_identifier(identifier: str) -> str: + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", identifier): + raise typer.BadParameter(f"Invalid SQL identifier: {identifier!r}") + return identifier + + def _palette(theme: ThemeMode) -> dict[str, str]: mode = _resolve_theme(theme) if mode == ThemeMode.light: @@ -914,6 +927,49 @@ def alembic_upgrade_and_data( typer.echo(f"applied {len(ran)} migration(s)") +@cli.command("refresh-pygeoapi-materialized-views") +def refresh_pygeoapi_materialized_views( + view: list[str] = typer.Option( + None, + "--view", + help=( + "Materialized view name(s) to refresh. Repeat --view for multiple. " + "Defaults to all pygeoapi materialized views." + ), + ), + concurrently: bool = typer.Option( + False, + "--concurrently/--no-concurrently", + help="Use REFRESH MATERIALIZED VIEW CONCURRENTLY.", + ), +): + from sqlalchemy import text + + from db.engine import engine, session_ctx + + target_views = tuple(view) if view else PYGEOAPI_MATERIALIZED_VIEWS + # Validate all view names before opening any DB connections or sessions. + safe_views = tuple(_validate_sql_identifier(v) for v in target_views) + + if concurrently: + # PostgreSQL requires REFRESH MATERIALIZED VIEW CONCURRENTLY to run + # outside of a transaction block, so we use an AUTOCOMMIT connection + # instead of a Session (which would wrap the call in a transaction). + with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: + for safe_view in safe_views: + conn.execute( + text(f"REFRESH MATERIALIZED VIEW CONCURRENTLY {safe_view}") + ) + else: + # Non-concurrent refresh can safely run inside a transaction. + with session_ctx() as session: + for safe_view in safe_views: + session.execute(text(f"REFRESH MATERIALIZED VIEW {safe_view}")) + session.commit() + + typer.echo(f"Refreshed {len(target_views)} materialized view(s).") + + if __name__ == "__main__": cli() diff --git a/core/app.py b/core/app.py index 4ce61a2fe..978419f6e 100644 --- a/core/app.py +++ b/core/app.py @@ -24,10 +24,6 @@ ) from fastapi.openapi.utils import get_openapi -from .initializers import ( - register_routes, - erase_and_rebuild_db, -) from .settings import settings @@ -41,7 +37,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: seed_all(10, skip_if_exists=True) - register_routes(app) yield diff --git a/core/initializers.py b/core/initializers.py index c3fe058fc..13a066fd3 100644 --- a/core/initializers.py +++ b/core/initializers.py @@ -14,6 +14,7 @@ # limitations under the License. # =============================================================================== from pathlib import Path +import os from fastapi_pagination import add_pagination from sqlalchemy import text, select @@ -65,6 +66,19 @@ def erase_and_rebuild_db(): session.execute(text("DROP SCHEMA public CASCADE")) session.execute(text("CREATE SCHEMA public")) session.execute(text("CREATE EXTENSION IF NOT EXISTS postgis")) + pg_cron_available = session.execute( + text( + "SELECT EXISTS (" + "SELECT 1 FROM pg_available_extensions WHERE name = 'pg_cron'" + ")" + ) + ).scalar() + if not pg_cron_available: + raise RuntimeError( + "Cannot erase and rebuild database: pg_cron extension is not " + "available on this PostgreSQL server." + ) + session.execute(text("CREATE EXTENSION IF NOT EXISTS pg_cron")) session.commit() Base.metadata.drop_all(session.bind) Base.metadata.create_all(session.bind) @@ -193,6 +207,9 @@ def init_lexicon(path: str = None) -> None: def register_routes(app): + if getattr(app.state, "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 @@ -211,7 +228,7 @@ 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 api.ogc.router import router as ogc_router + from core.pygeoapi import mount_pygeoapi app.include_router(asset_router) app.include_router(admin_auth_router) @@ -219,7 +236,7 @@ def register_routes(app): app.include_router(contact_router) app.include_router(geospatial_router) app.include_router(group_router) - app.include_router(ogc_router) + mount_pygeoapi(app) app.include_router(lexicon_router) app.include_router(location_router) app.include_router(observation_router) @@ -230,6 +247,58 @@ def register_routes(app): app.include_router(thing_router) app.include_router(ngwmn_router) add_pagination(app) + app.state.routes_registered = True + + +def configure_middleware(app): + from starlette.middleware.cors import CORSMiddleware + from starlette.middleware.sessions import SessionMiddleware + + if not getattr(app.state, "session_middleware_configured", False): + session_secret_key = os.environ.get("SESSION_SECRET_KEY") + if not session_secret_key: + raise ValueError("SESSION_SECRET_KEY environment variable is not set.") + app.add_middleware(SessionMiddleware, secret_key=session_secret_key) + app.state.session_middleware_configured = True + + if not getattr(app.state, "cors_middleware_configured", False): + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + app.state.cors_middleware_configured = True + + apitally_client_id = os.environ.get("APITALLY_CLIENT_ID") + if apitally_client_id and not getattr( + app.state, "apitally_middleware_configured", False + ): + from apitally.fastapi import ApitallyMiddleware + + app.add_middleware( + ApitallyMiddleware, + client_id=apitally_client_id, + env=os.environ.get("ENVIRONMENT"), + enable_request_logging=True, + log_request_headers=True, + log_request_body=True, + log_response_body=True, + capture_logs=True, + capture_traces=False, + ) + app.state.apitally_middleware_configured = True + + +def configure_admin(app): + if getattr(app.state, "admin_configured", False): + return + + from admin import create_admin + + create_admin(app) + app.state.admin_configured = True # ============= EOF ============================================= diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml new file mode 100644 index 000000000..4228b6cdb --- /dev/null +++ b/core/pygeoapi-config.yml @@ -0,0 +1,106 @@ +server: + bind: + host: 0.0.0.0 + port: 8000 + url: {server_url} + mimetype: application/json; charset=UTF-8 + encoding: utf-8 + language: en-US + limits: + default_items: 10 + max_items: 10000 + map: + url: https://tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png + attribution: "© OpenStreetMap contributors" + +logging: + level: INFO + +metadata: + identification: + title: Ocotillo OGC API + description: OGC API - Features backed by PostGIS and pygeoapi + keywords: [features, ogcapi, postgis, pygeoapi] + terms_of_service: https://example.com/terms + url: https://example.com + license: + name: CC-BY 4.0 + url: https://creativecommons.org/licenses/by/4.0/ + provider: + name: NMBGMR + url: https://geoinfo.nmt.edu + contact: + name: API Support + email: support@example.com + +resources: + locations: + type: collection + title: Locations + description: Geographic locations and site coordinates used by Ocotillo features. + keywords: [locations] + 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: location + geom_field: point + + latest_depth_to_water_wells: + type: collection + title: Latest Depth to Water (Wells) + description: Most recent depth-to-water below ground surface observation for each water well. + keywords: [water-wells, groundwater-level, depth-to-water-bgs, latest] + 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_latest_depth_to_water_wells + geom_field: point + + avg_tds_wells: + type: collection + title: Average TDS (Water Wells) + description: Average total dissolved solids (TDS) from major chemistry results for each water well. + keywords: [water-wells, chemistry, tds, total-dissolved-solids, average] + 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_avg_tds_wells + geom_field: point + +{thing_collections_block} diff --git a/core/pygeoapi.py b/core/pygeoapi.py new file mode 100644 index 000000000..223d699ea --- /dev/null +++ b/core/pygeoapi.py @@ -0,0 +1,333 @@ +import os +import textwrap +from importlib.util import find_spec +from pathlib import Path + +import yaml +from fastapi import FastAPI + +THING_COLLECTIONS = [ + { + "id": "water_wells", + "title": "Water Wells", + "thing_type": "water well", + "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.", + "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.", + "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.", + "keywords": ["ephemeral-stream", "surface-water"], + }, + { + "id": "lakes_ponds_reservoirs", + "title": "Lakes, Ponds, and Reservoirs", + "thing_type": "lake, pond or reservoir", + "description": "Surface-water bodies monitored as feature locations.", + "keywords": ["lake", "pond", "reservoir", "surface-water"], + }, + { + "id": "meteorological_stations", + "title": "Meteorological Stations", + "thing_type": "meteorological station", + "description": "Weather and climate monitoring station locations.", + "keywords": ["meteorological-station", "weather"], + }, + { + "id": "other_things", + "title": "Other Thing Types", + "thing_type": "other", + "description": "Feature records that do not match another defined thing type.", + "keywords": ["other"], + }, + { + "id": "outfalls_wastewater_return_flow", + "title": "Outfalls and Return Flow", + "thing_type": "outfall of wastewater or return flow", + "description": "Outfall and return-flow monitoring points.", + "keywords": ["outfall", "return-flow", "surface-water"], + }, + { + "id": "perennial_streams", + "title": "Perennial Streams", + "thing_type": "perennial stream", + "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.", + "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.", + "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"], + }, +] + + +def _template_path() -> Path: + return Path(__file__).resolve().parent / "pygeoapi-config.yml" + + +def _mount_path() -> str: + # Read and sanitize the configured mount path, defaulting to "/ogcapi". + path = (os.environ.get("PYGEOAPI_MOUNT_PATH", "/ogcapi") or "").strip() + + # Treat empty or root ("/") values as invalid and fall back to the default. + if path in {"", "/"}: + path = "/ogcapi" + + # Ensure a single leading slash. + if not path.startswith("/"): + path = f"/{path}" + + # Remove any trailing slashes so "/ogcapi/" and "ogcapi/" both become "/ogcapi". + path = path.rstrip("/") + return path + + +def _server_url() -> str: + configured = os.environ.get("PYGEOAPI_SERVER_URL") + if configured: + return configured.rstrip("/") + return f"http://localhost:8000{_mount_path()}" + + +def _pygeoapi_dir() -> Path: + # Use instance-local ephemeral storage by default (GAE-safe). + runtime_dir = (os.environ.get("PYGEOAPI_RUNTIME_DIR") or "").strip() + path = Path(runtime_dir) if runtime_dir else Path("/tmp/pygeoapi") + path.mkdir(parents=True, exist_ok=True) + return path + + +def _thing_collections_block( + host: str, + port: str, + dbname: str, + user: str, + password_placeholder: str, +) -> str: + resources: dict[str, dict] = {} + for collection in THING_COLLECTIONS: + resources[collection["id"]] = { + "type": "collection", + "title": collection["title"], + "description": collection["description"], + "keywords": collection["keywords"], + "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": host, + "port": port, + "dbname": dbname, + "user": user, + "password": password_placeholder, + "search_path": ["public"], + }, + "id_field": "id", + "table": f"ogc_{collection['id']}", + "geom_field": "point", + } + ], + } + + block = yaml.safe_dump( + resources, + sort_keys=False, + default_flow_style=False, + allow_unicode=False, + ).rstrip() + return textwrap.indent(block, " ") + + +def _pygeoapi_db_settings() -> tuple[str, str, str, str, str]: + host = (os.environ.get("PYGEOAPI_POSTGRES_HOST") or "").strip() or "127.0.0.1" + port = (os.environ.get("PYGEOAPI_POSTGRES_PORT") or "").strip() or "5432" + dbname = (os.environ.get("PYGEOAPI_POSTGRES_DB") or "").strip() or "postgres" + user = (os.environ.get("PYGEOAPI_POSTGRES_USER") or "").strip() + if not user: + raise RuntimeError( + "PYGEOAPI_POSTGRES_USER must be set and non-empty to generate the " + "pygeoapi configuration." + ) + if os.environ.get("PYGEOAPI_POSTGRES_PASSWORD") is None: + raise RuntimeError( + "PYGEOAPI_POSTGRES_PASSWORD must be set to " + "generate the pygeoapi configuration." + ) + return host, port, dbname, user, "${PYGEOAPI_POSTGRES_PASSWORD}" + + +def _write_config(path: Path) -> None: + host, port, dbname, user, password_placeholder = _pygeoapi_db_settings() + template = _template_path().read_text(encoding="utf-8") + config = template.format( + server_url=_server_url(), + postgres_host=host, + postgres_port=port, + postgres_db=dbname, + postgres_user=user, + postgres_password_env=password_placeholder, + thing_collections_block=_thing_collections_block( + host=host, + port=port, + dbname=dbname, + user=user, + password_placeholder=password_placeholder, + ), + ) + # NOTE: The generated runtime config file at + # `${PYGEOAPI_RUNTIME_DIR}/pygeoapi-config.yml` (default: + # `/tmp/pygeoapi/pygeoapi-config.yml`) contains database connection details + # (host, port, dbname, user). Although the password is expected to be + # provided via environment variables at runtime by pygeoapi, this file + # should still be treated as sensitive configuration: + # * Do not commit it to version control. + # * Do not expose it in logs, error messages, or diagnostics. + # * Ensure filesystem permissions restrict access appropriately. + path.write_text(config, encoding="utf-8") + + +def _generate_openapi(_config_path: Path, openapi_path: Path) -> None: + openapi = f"""openapi: 3.0.2 +info: + title: Ocotillo OGC API + version: 1.0.0 +servers: + - url: {_server_url()} +paths: {{}} +""" + openapi_path.write_text(openapi, encoding="utf-8") + + +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_dir = _pygeoapi_dir() + config_path = pygeoapi_dir / "pygeoapi-config.yml" + openapi_path = pygeoapi_dir / "pygeoapi-openapi.yml" + _write_config(config_path) + _generate_openapi(config_path, openapi_path) + + os.environ["PYGEOAPI_CONFIG"] = str(config_path) + os.environ["PYGEOAPI_OPENAPI"] = str(openapi_path) + + from pygeoapi.starlette_app import APP as pygeoapi_app + + mount_path = _mount_path() + app.mount(mount_path, pygeoapi_app) + + app.state.pygeoapi_mounted = True diff --git a/db/initialization.py b/db/initialization.py index fb016c44e..a9c5516d1 100644 --- a/db/initialization.py +++ b/db/initialization.py @@ -24,7 +24,16 @@ def _parse_app_read_members() -> list[str]: members = os.environ.get("APP_READ_MEMBERS", "") - return [member.strip() for member in members.split(",") if member.strip()] + parsed = [member.strip() for member in members.split(",") if member.strip()] + # NOTE: The "pygeoapi" database role is always added to APP_READ_MEMBERS. + # This ensures the pygeoapi integration consistently inherits the default + # read role ("app_read"), even if administrators do not list it explicitly + # in the APP_READ_MEMBERS environment variable. When reviewing database + # permissions or configuring roles, be aware that "pygeoapi" will always + # receive read access via app_read if the role exists in the database. + if "pygeoapi" not in {member.lower() for member in parsed}: + parsed.append("pygeoapi") + return parsed def grant_app_read_members(executor: Session | Connection | None) -> None: @@ -53,6 +62,19 @@ def recreate_public_schema(session: Session) -> None: session.execute(text("DROP SCHEMA public CASCADE")) session.execute(text("CREATE SCHEMA public")) session.execute(text("CREATE EXTENSION IF NOT EXISTS postgis")) + pg_cron_available = session.execute( + text( + "SELECT EXISTS (" + "SELECT 1 FROM pg_available_extensions WHERE name = 'pg_cron'" + ")" + ) + ).scalar() + if not pg_cron_available: + raise RuntimeError( + "Cannot initialize database schema: pg_cron extension is not available " + "on this PostgreSQL server." + ) + session.execute(text("CREATE EXTENSION IF NOT EXISTS pg_cron")) session.execute(APP_READ_GRANT_SQL) grant_app_read_members(session) session.commit() diff --git a/docker-compose.yml b/docker-compose.yml index bdcf7a776..5b82575a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,14 @@ services: db: - image: postgis/postgis:17-3.5 + build: + context: . + dockerfile: ./docker/db/Dockerfile platform: linux/amd64 + command: > + postgres + -c shared_preload_libraries=pg_cron + -c cron.database_name=${POSTGRES_DB} environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} diff --git a/docker/db/Dockerfile b/docker/db/Dockerfile new file mode 100644 index 000000000..57f2f8ea8 --- /dev/null +++ b/docker/db/Dockerfile @@ -0,0 +1,5 @@ +FROM postgis/postgis:17-3.5 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-17-cron \ + && rm -rf /var/lib/apt/lists/* diff --git a/main.py b/main.py index 852b5450e..fac816f26 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ from dotenv import load_dotenv -from core.initializers import register_routes +from core.initializers import configure_admin, configure_middleware, register_routes load_dotenv() DSN = os.environ.get("SENTRY_DSN") @@ -28,52 +28,16 @@ ) -from starlette.middleware.cors import CORSMiddleware -from starlette.middleware.sessions import SessionMiddleware +def create_app(): + from core.app import app as core_app -from core.app import app + register_routes(core_app) + configure_middleware(core_app) + configure_admin(core_app) + return core_app -register_routes(app) -# Session middleware is required for the admin auth flow (request.session access). -SESSION_SECRET_KEY = os.environ.get("SESSION_SECRET_KEY") -if not SESSION_SECRET_KEY: - raise ValueError("SESSION_SECRET_KEY environment variable is not set.") - -app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY) - -# ========== Starlette Admin Interface ========== -# Mount admin interface at /admin -# This provides a web-based UI for managing database records (replaces MS Access) -from admin import create_admin - -create_admin(app) -# ============================================== - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Allows all origins, adjust as needed for security - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -APITALLY_CLIENT_ID = os.environ.get("APITALLY_CLIENT_ID") -if APITALLY_CLIENT_ID: - from apitally.fastapi import ApitallyMiddleware - - app.add_middleware( - ApitallyMiddleware, - client_id=APITALLY_CLIENT_ID, - env=os.environ.get("ENVIRONMENT"), # "production" or "staging" - # Optionally enable and configure request logging - enable_request_logging=True, - log_request_headers=True, - log_request_body=True, - log_response_body=True, - capture_logs=True, - capture_traces=False, # requires instrumentation - ) +app = create_app() if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 45f81453e..0cbf8cc1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ dependencies = [ "pydantic-core==2.41.5", "pygments==2.19.2", "pyjwt==2.11.0", + "pygeoapi==0.22.0", "pyproj==3.7.2", "pyshp==2.3.1", "pytest==9.0.2", diff --git a/requirements.txt b/requirements.txt index cce9c8b58..24cd75ff8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,9 @@ # This file was autogenerated by uv via the following command: # uv export --format requirements-txt --no-emit-project --no-dev --output-file requirements.txt +affine==2.4.0 \ + --hash=sha256:8a3df80e2b2378aef598a83c1392efd47967afec4242021a0b06b4c7cbc61a92 \ + --hash=sha256:a24d818d6a836c131976d22f8c27b8d3ca32d0af64c1d8d29deb7bafa4da1eea + # via rasterio aiofiles==24.1.0 \ --hash=sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c \ --hash=sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5 @@ -147,7 +151,10 @@ attrs==25.4.0 \ --hash=sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373 # via # aiohttp + # jsonschema # ocotilloapi + # rasterio + # referencing authlib==1.6.8 \ --hash=sha256:41ae180a17cf672bc784e4a518e5c82687f1fe1e98b0cafaeda80c8e4ab2d1cb \ --hash=sha256:97286fd7a15e6cfefc32771c8ef9c54f0ed58028f1322de6a2a7c969c3817888 @@ -155,7 +162,9 @@ authlib==1.6.8 \ babel==2.17.0 \ --hash=sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d \ --hash=sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2 - # via starlette-admin + # via + # pygeoapi + # starlette-admin backoff==2.2.1 \ --hash=sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba \ --hash=sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8 @@ -205,6 +214,10 @@ bcrypt==4.3.0 \ --hash=sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef \ --hash=sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d # via ocotilloapi +blinker==1.9.0 \ + --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ + --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc + # via flask cachetools==5.5.2 \ --hash=sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4 \ --hash=sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a @@ -217,6 +230,7 @@ certifi==2025.8.3 \ # httpx # ocotilloapi # pyproj + # rasterio # requests # sentry-sdk cffi==1.17.1 \ @@ -281,9 +295,18 @@ click==8.3.1 \ --hash=sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a \ --hash=sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6 # via + # cligj + # flask # ocotilloapi + # pygeoapi + # pygeofilter + # rasterio # typer # uvicorn +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 @@ -373,6 +396,10 @@ cryptography==45.0.6 \ # cloud-sql-python-connector # google-auth # ocotilloapi +dateparser==1.3.0 \ + --hash=sha256:5bccf5d1ec6785e5be71cc7ec80f014575a09b4923e762f850e57443bddbf1a5 \ + --hash=sha256:8dc678b0a526e103379f02ae44337d424bd366aac727d3c6cf52ce1b01efbb5a + # via pygeofilter distlib==0.4.0 \ --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ --hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d @@ -410,7 +437,13 @@ fastapi-pagination==0.15.10 \ filelock==3.18.0 \ --hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \ --hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de - # via virtualenv + # via + # pygeoapi + # virtualenv +flask==3.1.3 \ + --hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \ + --hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c + # via pygeoapi frozenlist==1.8.0 \ --hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \ --hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \ @@ -621,13 +654,29 @@ iniconfig==2.3.0 \ itsdangerous==2.2.0 \ --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 - # via ocotilloapi + # via + # flask + # ocotilloapi jinja2==3.1.6 \ --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 # via + # flask # ocotilloapi + # pygeoapi # starlette-admin +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via pygeoapi +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +lark==1.3.1 \ + --hash=sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905 \ + --hash=sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12 + # via pygeofilter mako==1.3.10 \ --hash=sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28 \ --hash=sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59 @@ -685,9 +734,11 @@ markupsafe==3.0.3 \ --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 # via + # flask # jinja2 # mako # ocotilloapi + # werkzeug mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba @@ -823,6 +874,7 @@ numpy==2.4.2 \ # ocotilloapi # pandas # pandas-stubs + # rasterio # shapely opentelemetry-api==1.39.1 \ --hash=sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950 \ @@ -1101,6 +1153,7 @@ pydantic==2.12.5 \ # fastapi # fastapi-pagination # ocotilloapi + # pygeoapi pydantic-core==2.41.5 \ --hash=sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90 \ --hash=sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740 \ @@ -1148,6 +1201,20 @@ pydantic-core==2.41.5 \ # via # ocotilloapi # pydantic +pygeoapi==0.22.0 \ + --hash=sha256:0975e9efc5e7c70466f05b085b8093311718c40ee8ecd9a15ac803945e8d5ab8 \ + --hash=sha256:43689d6c89e6bd7536c9384db4617fa499f82823394a656dd50c2ea126c92150 + # via ocotilloapi +pygeofilter==0.3.3 \ + --hash=sha256:8b9fec05ba144943a1e415b6ac3752ad6011f44aad7d1bb27e7ef48b073460bd \ + --hash=sha256:e719fcb929c6b60bca99de0cfde5f95bc3245cab50516c103dae1d4f12c4c7b6 + # via pygeoapi +pygeoif==1.6.0 \ + --hash=sha256:02f84807dadbaf1941c4bb2a9ef1ebac99b1b0404597d2602efdbb58910c69c9 \ + --hash=sha256:eb0efa59c6573ea2cadce69a7ea9d2d10394b895ed47831c00d44752219c01be + # via + # pygeoapi + # pygeofilter pygments==2.19.2 \ --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b @@ -1159,6 +1226,10 @@ pyjwt==2.11.0 \ --hash=sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623 \ --hash=sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469 # via ocotilloapi +pyparsing==3.3.2 \ + --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \ + --hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc + # via rasterio pyproj==3.7.2 \ --hash=sha256:1914e29e27933ba6f9822663ee0600f169014a2859f851c054c88cf5ea8a333c \ --hash=sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1 \ @@ -1197,7 +1268,9 @@ pyproj==3.7.2 \ --hash=sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3 \ --hash=sha256:f7f5133dca4c703e8acadf6f30bc567d39a42c6af321e7f81975c2518f3ed357 \ --hash=sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd - # via ocotilloapi + # via + # ocotilloapi + # pygeoapi pyshp==2.3.1 \ --hash=sha256:4caec82fd8dd096feba8217858068bacb2a3b5950f43c048c6dc32a3489d5af1 \ --hash=sha256:67024c0ccdc352ba5db777c4e968483782dfa78f8e200672a90d2d30fd8b7b49 @@ -1216,9 +1289,11 @@ python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 # via + # dateparser # ocotilloapi # pandas # pg8000 + # pygeoapi python-dotenv==1.2.1 \ --hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \ --hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61 @@ -1237,8 +1312,10 @@ pytz==2025.2 \ --hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \ --hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00 # via + # dateparser # ocotilloapi # pandas + # pygeoapi pyyaml==6.0.2 \ --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \ --hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \ @@ -1250,7 +1327,109 @@ pyyaml==6.0.2 \ --hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \ --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \ --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba - # via pre-commit + # via + # pre-commit + # pygeoapi +rasterio==1.5.0 \ + --hash=sha256:015c1ab6e5453312c5e29692752e7ad73568fe4d13567cbd448d7893128cbd2d \ + --hash=sha256:08a7580cbb9b3bd320bdf827e10c9b2424d0df066d8eef6f2feb37e154ce0c17 \ + --hash=sha256:0c739e70a72fb080f039ee1570c5d02b974dde32ded1a3216e1f13fe38ac4844 \ + --hash=sha256:1162c18eaece9f6d2aa1c2ff6b373b99651d93f113f24120a991eaebf28aa4f4 \ + --hash=sha256:19577f0f0c5f1158af47b57f73356961cbd1782a5f6ae6f3adf6f2650f4eb369 \ + --hash=sha256:1e0ea56b02eea4989b36edf8e58a5a3ef40e1b7edcb04def2603accd5ab3ee7b \ + --hash=sha256:2f57c36ca4d3c896f7024226bd71eeb5cd10c8183c2a94508534d78cc05ff9e7 \ + --hash=sha256:508251b9c746d8d008771a30c2160ff321bfc3b41f6a1aa8e8ef1dd4a00d97ba \ + --hash=sha256:592a485e2057b1aaeab4f843c9897628e60e3ff45e2509325c3e1479116599cb \ + --hash=sha256:597be8df418d5ba7b6a927b6b9febfcb42b192882448a8d5b2e2e75a1296631f \ + --hash=sha256:62c3f97a3c72643c74f2d0f310621a09c35c0c412229c327ae6bcc1ee4b9c3bc \ + --hash=sha256:742841ed48bc70f6ef517b8fa3521f231780bf408fde0aa6d73770337a36374e \ + --hash=sha256:8af7c368c22f0a99d1259ccc5a5cd96c432c2bde6f132c1ac78508cd7445a745 \ + --hash=sha256:8eb87fd6f843eea109f3df9bef83f741b053b716b0465932276e2c0577dfb929 \ + --hash=sha256:a3539a2f401a7b4b2e94ff2db334878c0e15a2d1c9fe90bb0879c52f89367ae5 \ + --hash=sha256:b4ccfcc8ed9400e4f14efdf2005533fcf72048748b727f85ff89b9291ecdf98a \ + --hash=sha256:b9fd87a0b63ab5c6267dfb0bc96f54fdf49d000651b9ee85ed37798141cff046 \ + --hash=sha256:c9a9eee49ce9410c2f352b34c370bb3a96bb518b6a7f97b3a72ee4c835fd4b5c \ + --hash=sha256:cc1395475e4bb7032cd81dda4d5558061c4c7d5a50b1b5e146bdf9716d0b9353 \ + --hash=sha256:d7d6729c0739b5ec48c33686668a30e27f5bdb361093f180ee7818ff19665547 \ + --hash=sha256:dd292030d39d685c0b35eddef233e7f1cb8b43052578a3ec97a2da57799693be \ + --hash=sha256:e7b25b0a19975ccd511e507e6de45b0a2d8fb6802abe49bb726cf48588e34833 \ + --hash=sha256:f459db8953ba30ca04fcef2b5e1260eeeff0eae8158bd9c3d6adbe56289765cc \ + --hash=sha256:f4b9c2c3b5f10469eb9588f105086e68f0279e62cc9095c4edd245e3f9b88c8a \ + --hash=sha256:ff677c0a9d3ba667c067227ef2b76872488b37ff29b061bc3e576fad9baa3286 + # via pygeoapi +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # 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 + # via dateparser requests==2.32.5 \ --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf @@ -1259,10 +1438,74 @@ requests==2.32.5 \ # google-api-core # google-cloud-storage # ocotilloapi + # pygeoapi rich==14.3.2 \ --hash=sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69 \ --hash=sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8 # via typer +rpds-py==0.30.0 \ + --hash=sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136 \ + --hash=sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7 \ + --hash=sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65 \ + --hash=sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2 \ + --hash=sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4 \ + --hash=sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3 \ + --hash=sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa \ + --hash=sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6 \ + --hash=sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87 \ + --hash=sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856 \ + --hash=sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f \ + --hash=sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53 \ + --hash=sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad \ + --hash=sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db \ + --hash=sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27 \ + --hash=sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18 \ + --hash=sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083 \ + --hash=sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898 \ + --hash=sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7 \ + --hash=sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08 \ + --hash=sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6 \ + --hash=sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551 \ + --hash=sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0 \ + --hash=sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2 \ + --hash=sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0 \ + --hash=sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404 \ + --hash=sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7 \ + --hash=sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb \ + --hash=sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15 \ + --hash=sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6 \ + --hash=sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e \ + --hash=sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95 \ + --hash=sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950 \ + --hash=sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e \ + --hash=sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e \ + --hash=sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8 \ + --hash=sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d \ + --hash=sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f \ + --hash=sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8 \ + --hash=sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f \ + --hash=sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d \ + --hash=sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07 \ + --hash=sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31 \ + --hash=sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94 \ + --hash=sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000 \ + --hash=sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1 \ + --hash=sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40 \ + --hash=sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0 \ + --hash=sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84 \ + --hash=sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419 \ + --hash=sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8 \ + --hash=sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a \ + --hash=sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9 \ + --hash=sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be \ + --hash=sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed \ + --hash=sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d \ + --hash=sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f \ + --hash=sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2 \ + --hash=sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5 + # via + # jsonschema + # referencing rsa==4.9.1 \ --hash=sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762 \ --hash=sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75 @@ -1314,7 +1557,9 @@ shapely==2.1.2 \ --hash=sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26 \ --hash=sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df \ --hash=sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e - # via ocotilloapi + # via + # ocotilloapi + # pygeoapi shellingham==1.5.4 \ --hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \ --hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de @@ -1359,6 +1604,7 @@ sqlalchemy==2.0.46 \ # alembic # geoalchemy2 # ocotilloapi + # pygeoapi # sqlalchemy-continuum # sqlalchemy-searchable # sqlalchemy-utils @@ -1388,6 +1634,10 @@ starlette-admin==0.16.0 \ --hash=sha256:9b7ee51cc275684ba75dda5eafc650e0c8afa1d2b7e99e4d1c83fe7d1e83de9e \ --hash=sha256:e706a1582a22a69202d3165d8c626d5868822c229353a81e1d189666d8418f64 # via ocotilloapi +tinydb==4.8.2 \ + --hash=sha256:f7dfc39b8d7fda7a1ca62a8dbb449ffd340a117c1206b68c50b1a481fb95181d \ + --hash=sha256:f97030ee5cbc91eeadd1d7af07ab0e48ceb04aa63d4a983adbaca4cba16e86c3 + # via pygeoapi typer==0.23.1 \ --hash=sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134 \ --hash=sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e @@ -1409,6 +1659,7 @@ typing-extensions==4.15.0 \ # opentelemetry-semantic-conventions # pydantic # pydantic-core + # pygeoif # sqlalchemy # typing-inspection typing-inspection==0.4.2 \ @@ -1424,6 +1675,11 @@ tzdata==2025.3 \ # via # ocotilloapi # pandas + # tzlocal +tzlocal==5.3.1 \ + --hash=sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd \ + --hash=sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d + # via dateparser urllib3==2.6.3 \ --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 @@ -1443,6 +1699,10 @@ virtualenv==20.32.0 \ --hash=sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56 \ --hash=sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0 # via pre-commit +werkzeug==3.1.6 \ + --hash=sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25 \ + --hash=sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131 + # via flask yarl==1.22.0 \ --hash=sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a \ --hash=sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da \ diff --git a/tests/__init__.py b/tests/__init__.py index 24b7a68f3..b5cee0114 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -26,27 +26,14 @@ os.environ["POSTGRES_PORT"] = "5432" # Always use test database, never dev os.environ["POSTGRES_DB"] = "ocotilloapi_test" +# Keep `main:app` importable in clean test environments without a local `.env`. +os.environ.setdefault("SESSION_SECRET_KEY", "test-session-secret-key") from fastapi.testclient import TestClient -from fastapi_pagination import add_pagination -from starlette.middleware.cors import CORSMiddleware -from core.initializers import register_routes from db import Parameter, Base from db.engine import session_ctx -from core.app import app - -register_routes(app) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Allows all origins, adjust as needed for security - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -add_pagination(app) +from main import app client = TestClient(app) diff --git a/tests/conftest.py b/tests/conftest.py index 50423ad8e..3847263b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,8 @@ def pytest_configure(): load_dotenv(override=True) 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") # Always use test database, never dev os.environ["POSTGRES_DB"] = "ocotilloapi_test" diff --git a/tests/features/steps/api_common.py b/tests/features/steps/api_common.py index 98d14cd9c..94b95e2f4 100644 --- a/tests/features/steps/api_common.py +++ b/tests/features/steps/api_common.py @@ -21,7 +21,6 @@ admin_function, amp_admin_function, ) -from core.initializers import register_routes from starlette.testclient import TestClient @@ -31,9 +30,7 @@ def step_given_api_is_running(context): Ensures the API app is initialized and client is ready. Behave will keep 'context' across steps, allowing us to reuse response data. """ - from core.app import app - - register_routes(app) + from main import app def override_authentication(default=True): """ diff --git a/tests/features/steps/cli_common.py b/tests/features/steps/cli_common.py index 3de5e408e..1483db09d 100644 --- a/tests/features/steps/cli_common.py +++ b/tests/features/steps/cli_common.py @@ -23,7 +23,6 @@ admin_function, amp_admin_function, ) -from core.initializers import register_routes @given("a functioning cli") @@ -32,9 +31,7 @@ def step_given_cli_is_running(context): Initializes app/auth context needed by CLI-backed feature tests that still perform DB-backed assertions. """ - from core.app import app - - register_routes(app) + from main import app def override_authentication(default=True): def closure(): diff --git a/tests/test_asset.py b/tests/test_asset.py index 539e8b90e..008cade90 100644 --- a/tests/test_asset.py +++ b/tests/test_asset.py @@ -19,7 +19,7 @@ import pytest from api.asset import get_storage_bucket -from core.app import app +from main import app from core.dependencies import viewer_function, admin_function, editor_function from db import Asset from schemas import DT_FMT diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 412ebea3c..8a89be835 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -29,6 +29,95 @@ from db.engine import session_ctx +def test_refresh_pygeoapi_materialized_views_defaults(monkeypatch): + executed_sql: list[str] = [] + commit_called = {"value": False} + + class FakeSession: + def execute(self, stmt): + executed_sql.append(str(stmt)) + + def commit(self): + commit_called["value"] = True + + class _FakeCtx: + def __enter__(self): + return FakeSession() + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("db.engine.session_ctx", lambda: _FakeCtx()) + + runner = CliRunner() + result = runner.invoke(cli, ["refresh-pygeoapi-materialized-views"]) + + assert result.exit_code == 0, result.output + assert executed_sql == [ + "REFRESH MATERIALIZED VIEW ogc_latest_depth_to_water_wells", + "REFRESH MATERIALIZED VIEW ogc_avg_tds_wells", + ] + assert commit_called["value"] is True + assert "Refreshed 2 materialized view(s)." in result.output + + +def test_refresh_pygeoapi_materialized_views_custom_and_concurrently(monkeypatch): + executed_sql: list[str] = [] + execution_options: list[dict[str, object]] = [] + + class FakeConnection: + def execution_options(self, **kwargs): + execution_options.append(kwargs) + return self + + def execute(self, stmt): + executed_sql.append(str(stmt)) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + class FakeEngine: + def connect(self): + return FakeConnection() + + monkeypatch.setattr("db.engine.engine", FakeEngine()) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "refresh-pygeoapi-materialized-views", + "--view", + "ogc_avg_tds_wells", + "--concurrently", + ], + ) + + assert result.exit_code == 0, result.output + assert execution_options == [{"isolation_level": "AUTOCOMMIT"}] + assert executed_sql == [ + "REFRESH MATERIALIZED VIEW CONCURRENTLY ogc_avg_tds_wells", + ] + + +def test_refresh_pygeoapi_materialized_views_rejects_invalid_identifier(): + runner = CliRunner() + result = runner.invoke( + cli, + [ + "refresh-pygeoapi-materialized-views", + "--view", + "ogc_avg_tds_wells;drop table thing", + ], + ) + + assert result.exit_code != 0 + assert "Invalid SQL identifier" in result.output + + def test_initialize_lexicon_invokes_initializer(monkeypatch): called = {"count": 0} diff --git a/tests/test_ogc.py b/tests/test_ogc.py index cc017367b..364d00660 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -14,6 +14,7 @@ # limitations under the License. # =============================================================================== import pytest +from importlib.util import find_spec from core.dependencies import ( admin_function, @@ -26,6 +27,11 @@ from main import app from tests import client, override_authentication +pytestmark = pytest.mark.skipif( + find_spec("pygeoapi") is None, + reason="pygeoapi is not installed in this environment", +) + @pytest.fixture(scope="module", autouse=True) def override_authentication_dependency_fixture(): @@ -50,7 +56,7 @@ def override_authentication_dependency_fixture(): def test_ogc_landing(): - response = client.get("/ogc") + response = client.get("/ogcapi") assert response.status_code == 200 payload = response.json() assert payload["title"] @@ -58,7 +64,7 @@ def test_ogc_landing(): def test_ogc_conformance(): - response = client.get("/ogc/conformance") + response = client.get("/ogcapi/conformance") assert response.status_code == 200 payload = response.json() assert "conformsTo" in payload @@ -66,17 +72,17 @@ def test_ogc_conformance(): def test_ogc_collections(): - response = client.get("/ogc/collections") + response = client.get("/ogcapi/collections") assert response.status_code == 200 payload = response.json() ids = {collection["id"] for collection in payload["collections"]} - assert {"locations", "wells", "springs"}.issubset(ids) + assert {"locations", "water_wells", "springs"}.issubset(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"/ogc/collections/locations/items?bbox={bbox}") + response = client.get(f"/ogcapi/collections/locations/items?bbox={bbox}") assert response.status_code == 200 payload = response.json() assert payload["type"] == "FeatureCollection" @@ -84,24 +90,26 @@ def test_ogc_locations_items_bbox(location): def test_ogc_wells_items_and_item(water_well_thing): - response = client.get("/ogc/collections/wells/items?properties=name='Test Well'") + response = client.get("/ogcapi/collections/water_wells/items?limit=20") assert response.status_code == 200 payload = response.json() assert payload["numberReturned"] >= 1 - feature = payload["features"][0] - assert feature["properties"]["name"] == "Test Well" + ids = {str(feature["id"]) for feature in payload["features"]} + assert str(water_well_thing.id) in ids - response = client.get(f"/ogc/collections/wells/items/{water_well_thing.id}") + response = client.get( + f"/ogcapi/collections/water_wells/items/{water_well_thing.id}" + ) assert response.status_code == 200 payload = response.json() - assert payload["id"] == water_well_thing.id + assert str(payload["id"]) == str(water_well_thing.id) @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( - "/ogc/collections/locations/items", + "/ogcapi/collections/locations/items", params={ "filter": f"WITHIN(geometry,{polygon})", "filter-lang": "cql2-text", diff --git a/transfers/README.md b/transfers/README.md index 48a5743a7..08e032349 100644 --- a/transfers/README.md +++ b/transfers/README.md @@ -25,3 +25,44 @@ Avoid ORM-heavy per-row object construction for bulk workloads. - Logs: `transfers/logs/` - Metrics: `transfers/metrics/` + +## Transfer Auditing CLI + +Use the transfer-auditing CLI to compare each source CSV against the current destination Postgres table. + +### Run + +```bash +source .venv/bin/activate +set -a; source .env; set +a +oco transfer-results +``` + +### Useful options + +```bash +oco transfer-results --sample-limit 5 +oco transfer-results --summary-path transfers/metrics/transfer_results_summary.md +``` + +- `--sample-limit`: limits sampled key details retained internally per transfer result. +- `--summary-path`: path to the markdown report. + +If `oco` is not on your PATH, use: + +```bash +python -m cli.cli transfer-results --sample-limit 5 +``` + +### Output + +Default report file: + +- `transfers/metrics/transfer_results_summary.md` + +Summary columns: + +- `Source Rows`: raw row count in the source CSV. +- `Agreed Rows`: rows considered in-scope by transfer rules/toggles. +- `Dest Rows`: current row count in destination table/model. +- `Missing Agreed`: `Agreed Rows - Dest Rows` (positive means destination is short vs agreed source rows). diff --git a/transfers/transfer.py b/transfers/transfer.py index 83b8df3b6..ff37d4af9 100644 --- a/transfers/transfer.py +++ b/transfers/transfer.py @@ -106,6 +106,7 @@ class TransferOptions: transfer_screens: bool transfer_sensors: bool transfer_contacts: bool + transfer_permissions: bool transfer_waterlevels: bool transfer_pressure: bool transfer_acoustic: bool @@ -147,6 +148,7 @@ def load_transfer_options() -> TransferOptions: transfer_screens=get_bool_env("TRANSFER_WELL_SCREENS", True), transfer_sensors=get_bool_env("TRANSFER_SENSORS", True), transfer_contacts=get_bool_env("TRANSFER_CONTACTS", True), + transfer_permissions=get_bool_env("TRANSFER_PERMISSIONS", True), transfer_waterlevels=get_bool_env("TRANSFER_WATERLEVELS", True), transfer_pressure=get_bool_env("TRANSFER_WATERLEVELS_PRESSURE", True), transfer_acoustic=get_bool_env("TRANSFER_WATERLEVELS_ACOUSTIC", True), @@ -570,9 +572,6 @@ def _transfer_parallel( ) futures[future] = "StratigraphyNew" - future = executor.submit(_execute_permissions_with_timing, "Permissions") - futures[future] = "Permissions" - # Collect results for future in as_completed(futures): name = futures[future] @@ -632,6 +631,17 @@ def _transfer_parallel( if "WeatherPhotos" in results_map and results_map["WeatherPhotos"]: metrics.weather_photos_metrics(*results_map["WeatherPhotos"]) + if opts.transfer_permissions: + # Permissions require contact associations; run after group 1 completes. + try: + result_name, result, elapsed = _execute_permissions_with_timing( + "Permissions" + ) + results_map[result_name] = result + logger.info(f"Task {result_name} completed in {elapsed:.2f}s") + except Exception as e: + logger.critical(f"Task Permissions failed: {e}") + if opts.transfer_major_chemistry: message("TRANSFERRING MAJOR CHEMISTRY") results = _execute_transfer(MajorChemistryTransferer, flags=flags) diff --git a/transfers/transfer_results_builder.py b/transfers/transfer_results_builder.py index 296529cdd..42e7c49b2 100644 --- a/transfers/transfer_results_builder.py +++ b/transfers/transfer_results_builder.py @@ -7,7 +7,7 @@ import pandas as pd from sqlalchemy import select, func -from db import Deployment, Sensor, Thing +from db import Deployment, PermissionHistory, Sensor, Thing, ThingContactAssociation from db.engine import session_ctx from transfers.sensor_transfer import ( EQUIPMENT_TO_SENSOR_TYPE_MAP, @@ -165,6 +165,76 @@ def _equipment_destination_series(session) -> pd.Series: return pointid + "|" + serial + "|" + installed + "|" + removed +def _permissions_source_series(session) -> pd.Series: + wdf = read_csv("WellData", dtype={"OSEWelltagID": str}) + wdf = replace_nans(wdf) + if "PointID" not in wdf.columns: + return pd.Series([], dtype=object) + + eligible_rows = ( + session.query(Thing.name) + .join(ThingContactAssociation, ThingContactAssociation.thing_id == Thing.id) + .filter(Thing.thing_type == "water well") + .filter(Thing.name.is_not(None)) + .distinct() + .all() + ) + eligible_pointids = {name for (name,) in eligible_rows if name} + if not eligible_pointids: + return pd.Series([], dtype=object) + + rows: list[str] = [] + for row in wdf.itertuples(index=False): + pointid = getattr(row, "PointID", None) + if pointid not in eligible_pointids: + continue + + sample_ok = getattr(row, "SampleOK", None) + if sample_ok is not None: + rows.append( + f"{_normalize_key(pointid)}|Water Chemistry Sample|{bool(sample_ok)}" + ) + + monitor_ok = getattr(row, "MonitorOK", None) + if monitor_ok is not None: + rows.append( + f"{_normalize_key(pointid)}|Water Level Sample|{bool(monitor_ok)}" + ) + + if not rows: + return pd.Series([], dtype=object) + return pd.Series(rows, dtype=object) + + +def _permissions_destination_series(session) -> pd.Series: + sql = ( + select( + Thing.name.label("point_id"), + PermissionHistory.permission_type.label("permission_type"), + PermissionHistory.permission_allowed.label("permission_allowed"), + ) + .select_from(PermissionHistory) + .join(Thing, Thing.id == PermissionHistory.target_id) + .where(PermissionHistory.target_table == "thing") + .where( + PermissionHistory.permission_type.in_( + ("Water Chemistry Sample", "Water Level Sample") + ) + ) + .where(Thing.name.is_not(None)) + ) + rows = session.execute(sql).all() + if not rows: + return pd.Series([], dtype=object) + return pd.Series( + [ + f"{_normalize_key(r.point_id)}|{r.permission_type}|{bool(r.permission_allowed)}" + for r in rows + ], + dtype=object, + ) + + class TransferResultsBuilder: """Compare transfer input CSV keys to destination database keys per transfer.""" @@ -183,6 +253,9 @@ def build(self) -> TransferComparisonResults: ) def _build_one(self, spec: TransferComparisonSpec) -> TransferResult: + if spec.transfer_name == "Permissions": + return self._build_permissions(spec) + source_df = read_csv(spec.source_csv) if spec.source_filter: source_df = spec.source_filter(source_df) @@ -277,6 +350,78 @@ def _build_one(self, spec: TransferComparisonSpec) -> TransferResult: extra_in_destination_sample=extra[: self.sample_limit], ) + def _build_permissions(self, spec: TransferComparisonSpec) -> TransferResult: + source_df = read_csv(spec.source_csv, dtype={"OSEWelltagID": str}) + source_row_count = len(source_df) + enabled = self._is_enabled(spec) + + with session_ctx() as session: + source_series = ( + _permissions_source_series(session) + if enabled + else pd.Series([], dtype=object) + ) + source_keys = set(source_series.unique().tolist()) + source_keyed_row_count = int(source_series.shape[0]) + source_duplicate_key_row_count = source_keyed_row_count - len(source_keys) + agreed_transfer_row_count = source_keyed_row_count + + destination_series = _permissions_destination_series(session) + destination_row_count = int( + session.execute( + select(func.count()) + .select_from(PermissionHistory) + .where(PermissionHistory.target_table == "thing") + .where( + PermissionHistory.permission_type.in_( + ("Water Chemistry Sample", "Water Level Sample") + ) + ) + ).scalar_one() + ) + + if destination_series.empty: + destination_series = pd.Series([], dtype=object) + else: + destination_series = destination_series.astype(str) + + destination_keys = set(destination_series.unique().tolist()) + destination_keyed_row_count = int(destination_series.shape[0]) + destination_duplicate_key_row_count = destination_keyed_row_count - len( + destination_keys + ) + missing = sorted(source_keys - destination_keys) + extra = sorted(destination_keys - source_keys) + transferred_agreed_row_count = int(source_series.isin(destination_keys).sum()) + missing_agreed_row_count = max( + agreed_transfer_row_count - transferred_agreed_row_count, + 0, + ) + + return spec.result_cls( + transfer_name=spec.transfer_name, + source_csv=spec.source_csv, + source_key_column=spec.source_key_column, + destination_model="PermissionHistory", + destination_key_column=spec.destination_key_column, + source_row_count=source_row_count, + agreed_transfer_row_count=agreed_transfer_row_count, + source_keyed_row_count=source_keyed_row_count, + source_key_count=len(source_keys), + source_duplicate_key_row_count=source_duplicate_key_row_count, + destination_row_count=destination_row_count, + destination_keyed_row_count=destination_keyed_row_count, + destination_key_count=len(destination_keys), + destination_duplicate_key_row_count=destination_duplicate_key_row_count, + matched_key_count=len(source_keys & destination_keys), + missing_in_destination_count=len(missing), + extra_in_destination_count=len(extra), + transferred_agreed_row_count=transferred_agreed_row_count, + missing_agreed_row_count=missing_agreed_row_count, + missing_in_destination_sample=missing[: self.sample_limit], + extra_in_destination_sample=extra[: self.sample_limit], + ) + def _is_enabled(self, spec: TransferComparisonSpec) -> bool: if not spec.option_field: return True diff --git a/transfers/transfer_results_specs.py b/transfers/transfer_results_specs.py index c117e7b3b..5a23f40bc 100644 --- a/transfers/transfer_results_specs.py +++ b/transfers/transfer_results_specs.py @@ -28,6 +28,7 @@ NMA_view_NGWMN_WaterLevels, NMA_view_NGWMN_WellConstruction, Observation, + PermissionHistory, Sensor, Thing, WellScreen, @@ -58,6 +59,7 @@ OtherSiteTypesTransferResult, OutfallWastewaterReturnFlowTransferResult, OwnersDataTransferResult, + PermissionsTransferResult, PerennialStreamsTransferResult, PressureDailyTransferResult, ProjectsTransferResult, @@ -516,6 +518,15 @@ def _record_new_contact( destination_where=lambda m: m.nma_pk_owners.is_not(None), option_field="transfer_contacts", ), + TransferComparisonSpec( + "Permissions", + PermissionsTransferResult, + "WellData", + "PointID|PermissionType|PermissionAllowed", + PermissionHistory, + "thing.name|permission_type|permission_allowed", + option_field="transfer_permissions", + ), TransferComparisonSpec( "WaterLevels", WaterLevelsTransferResult, diff --git a/transfers/transfer_results_types.py b/transfers/transfer_results_types.py index 1163a2c7e..5759b7c92 100644 --- a/transfers/transfer_results_types.py +++ b/transfers/transfer_results_types.py @@ -38,6 +38,7 @@ class TransferComparisonResults: "WellData", "WellScreens", "OwnersData", + "Permissions", "WaterLevels", "Equipment", "Projects", diff --git a/uv.lock b/uv.lock index faba9d954..eb03c2320 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "affine" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/98/d2f0bb06385069e799fc7d2870d9e078cfa0fa396dc8a2b81227d0da08b9/affine-2.4.0.tar.gz", hash = "sha256:a24d818d6a836c131976d22f8c27b8d3ca32d0af64c1d8d29deb7bafa4da1eea", size = 17132, upload-time = "2023-01-19T23:44:30.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/f7/85273299ab57117850cc0a936c64151171fac4da49bc6fba0dad984a7c5f/affine-2.4.0-py3-none-any.whl", hash = "sha256:8a3df80e2b2378aef598a83c1392efd47967afec4242021a0b06b4c7cbc61a92", size = 15662, upload-time = "2023-01-19T23:44:28.833Z" }, +] + [[package]] name = "aiofiles" version = "24.1.0" @@ -357,6 +366,15 @@ wheels = [ { 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" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "cachetools" version = "5.5.2" @@ -459,6 +477,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] +[[package]] +name = "cligj" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/0d/837dbd5d8430fd0f01ed72c4cfb2f548180f4c68c635df84ce87956cff32/cligj-0.7.2.tar.gz", hash = "sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27", size = 9803, upload-time = "2021-05-28T21:23:27.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/86/43fa9f15c5b9fb6e82620428827cd3c284aa933431405d1bcf5231ae3d3e/cligj-0.7.2-py3-none-any.whl", hash = "sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df", size = 7069, upload-time = "2021-05-28T21:23:26.877Z" }, +] + [[package]] name = "cloud-sql-python-connector" version = "1.20.0" @@ -591,6 +621,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/51/51ae3ab3b8553ec61f6558e9a0a9e8c500a9db844f9cf00a732b19c9a6ea/cucumber_tag_expressions-8.0.0-py3-none-any.whl", hash = "sha256:bfe552226f62a4462ee91c9643582f524af84ac84952643fb09057580cbb110a", size = 9726, upload-time = "2025-10-14T17:01:26.098Z" }, ] +[[package]] +name = "dateparser" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/2c/668dfb8c073a5dde3efb80fa382de1502e3b14002fd386a8c1b0b49e92a9/dateparser-1.3.0.tar.gz", hash = "sha256:5bccf5d1ec6785e5be71cc7ec80f014575a09b4923e762f850e57443bddbf1a5", size = 337152, upload-time = "2026-02-04T16:00:06.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/c7/95349670e193b2891176e1b8e5f43e12b31bff6d9994f70e74ab385047f6/dateparser-1.3.0-py3-none-any.whl", hash = "sha256:8dc678b0a526e103379f02ae44337d424bd366aac727d3c6cf52ce1b01efbb5a", size = 318688, upload-time = "2026-02-04T16:00:04.652Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -710,6 +755,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -1041,6 +1103,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + [[package]] name = "mako" version = "1.3.10" @@ -1355,6 +1453,7 @@ dependencies = [ { name = "pycparser" }, { name = "pydantic" }, { name = "pydantic-core" }, + { name = "pygeoapi" }, { name = "pygments" }, { name = "pyjwt" }, { name = "pyproj" }, @@ -1468,6 +1567,7 @@ requires-dist = [ { name = "pycparser", specifier = "==2.23" }, { 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 = "pyproj", specifier = "==3.7.2" }, @@ -2014,6 +2114,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, ] +[[package]] +name = "pygeoapi" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "click" }, + { name = "filelock" }, + { name = "flask" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pygeofilter" }, + { name = "pygeoif" }, + { name = "pyproj" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "pyyaml" }, + { name = "rasterio" }, + { name = "requests" }, + { name = "shapely" }, + { name = "sqlalchemy" }, + { name = "tinydb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/46/3bcdd2915a8f2a9856cb0442f3f73cbba463bff4c5c059887dc3a20de33a/pygeoapi-0.22.0.tar.gz", hash = "sha256:43689d6c89e6bd7536c9384db4617fa499f82823394a656dd50c2ea126c92150", size = 324148, upload-time = "2025-11-07T20:22:43.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/3d/a3dd54ac1870c99223fc2fc1981ac16f3a875d95c0d60fca0814c393ca8f/pygeoapi-0.22.0-py2.py3-none-any.whl", hash = "sha256:0975e9efc5e7c70466f05b085b8093311718c40ee8ecd9a15ac803945e8d5ab8", size = 518476, upload-time = "2025-11-07T20:22:41.982Z" }, +] + +[[package]] +name = "pygeofilter" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "dateparser" }, + { name = "lark" }, + { name = "pygeoif" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f0/30b916dc05ff1242eb9cc391e1bac367d34c9f403c0bd634923b87024c23/pygeofilter-0.3.3.tar.gz", hash = "sha256:8b9fec05ba144943a1e415b6ac3752ad6011f44aad7d1bb27e7ef48b073460bd", size = 63419, upload-time = "2025-12-20T08:47:59.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/e3/c777c08e9519c1d49fcfad726c84d7b0e7934e9f414430eaa3d1ab41ecf7/pygeofilter-0.3.3-py2.py3-none-any.whl", hash = "sha256:e719fcb929c6b60bca99de0cfde5f95bc3245cab50516c103dae1d4f12c4c7b6", size = 96568, upload-time = "2025-12-20T08:47:58.178Z" }, +] + +[[package]] +name = "pygeoif" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/c6660ceea2fc28feefdfb0389bf53b5d0e0ba92aaba72e813901cb0552ed/pygeoif-1.6.0.tar.gz", hash = "sha256:eb0efa59c6573ea2cadce69a7ea9d2d10394b895ed47831c00d44752219c01be", size = 40915, upload-time = "2025-10-01T10:02:13.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/7f/c803c39fa76fe055bc4154fb6e897185ad21946820a2227283e0a20eeb35/pygeoif-1.6.0-py3-none-any.whl", hash = "sha256:02f84807dadbaf1941c4bb2a9ef1ebac99b1b0404597d2602efdbb58910c69c9", size = 27976, upload-time = "2025-10-01T10:02:12.19Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -2041,6 +2197,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pyproj" version = "3.7.2" @@ -2221,6 +2386,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "rasterio" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "affine" }, + { name = "attrs" }, + { name = "certifi" }, + { name = "click" }, + { name = "cligj" }, + { name = "numpy" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/88/edb4b66b6cb2c13f123af5a3896bf70c0cbe73ab3cd4243cb4eb0212a0f6/rasterio-1.5.0.tar.gz", hash = "sha256:1e0ea56b02eea4989b36edf8e58a5a3ef40e1b7edcb04def2603accd5ab3ee7b", size = 452184, upload-time = "2026-01-05T16:06:47.169Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/87/42865a77cebf2e524d27b6afc71db48984799ecd1dbe6a213d4713f42f5f/rasterio-1.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e7b25b0a19975ccd511e507e6de45b0a2d8fb6802abe49bb726cf48588e34833", size = 22776107, upload-time = "2026-01-05T16:05:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e81683fbbfdf04e019e68b042d9cff8524b0571aa80e4f4d81c373c31a49/rasterio-1.5.0-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1162c18eaece9f6d2aa1c2ff6b373b99651d93f113f24120a991eaebf28aa4f4", size = 24401477, upload-time = "2026-01-05T16:05:39.702Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3c/6aa6e0690b18eea02a61739cb362a47c5df66138f0a02cc69e1181b964e5/rasterio-1.5.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:8eb87fd6f843eea109f3df9bef83f741b053b716b0465932276e2c0577dfb929", size = 36018214, upload-time = "2026-01-05T16:05:42.741Z" }, + { url = "https://files.pythonhosted.org/packages/48/4a/1af9aa9810fb30668568f2c4dd3eec2412c8e9762b69201d971c509b295e/rasterio-1.5.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:08a7580cbb9b3bd320bdf827e10c9b2424d0df066d8eef6f2feb37e154ce0c17", size = 37544972, upload-time = "2026-01-05T16:05:45.815Z" }, + { url = "https://files.pythonhosted.org/packages/01/62/bfe3408743c9837919ff232474a09ece9eaa88d4ee8c040711fa3dff6dad/rasterio-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:d7d6729c0739b5ec48c33686668a30e27f5bdb361093f180ee7818ff19665547", size = 30140141, upload-time = "2026-01-05T16:05:48.751Z" }, + { url = "https://files.pythonhosted.org/packages/63/ca/e90e19a6d065a718cc3d468a12b9f015289ad17017656dea8c76f7318d1f/rasterio-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:8af7c368c22f0a99d1259ccc5a5cd96c432c2bde6f132c1ac78508cd7445a745", size = 28498556, upload-time = "2026-01-05T16:05:51.334Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ba/e37462d8c33bbbd6c152a0390ec6911a3d9614ded3d2bc6f6a48e147e833/rasterio-1.5.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:b4ccfcc8ed9400e4f14efdf2005533fcf72048748b727f85ff89b9291ecdf98a", size = 22920107, upload-time = "2026-01-05T16:05:53.773Z" }, + { url = "https://files.pythonhosted.org/packages/66/dc/7bfa9cf96ac39b451b2f94dfc584c223ec584c52c148df2e4bab60c3341b/rasterio-1.5.0-cp313-cp313t-macosx_15_0_x86_64.whl", hash = "sha256:2f57c36ca4d3c896f7024226bd71eeb5cd10c8183c2a94508534d78cc05ff9e7", size = 24508993, upload-time = "2026-01-05T16:05:57.062Z" }, + { url = "https://files.pythonhosted.org/packages/e5/55/7293743f3b69de4b726c67b8dc9da01fc194070b6becc51add4ca8a20a27/rasterio-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cc1395475e4bb7032cd81dda4d5558061c4c7d5a50b1b5e146bdf9716d0b9353", size = 36565784, upload-time = "2026-01-05T16:06:00.019Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ef/5354c47de16c6e289728c3a3d6961ffcf7a9ad6313aef7e8db5d6a40c46e/rasterio-1.5.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:592a485e2057b1aaeab4f843c9897628e60e3ff45e2509325c3e1479116599cb", size = 37686456, upload-time = "2026-01-05T16:06:02.772Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fc/fe1f034b1acd1900d9fbd616826d001a3d5811f1d0c97c785f88f525853e/rasterio-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0c739e70a72fb080f039ee1570c5d02b974dde32ded1a3216e1f13fe38ac4844", size = 30355842, upload-time = "2026-01-05T16:06:06.359Z" }, + { url = "https://files.pythonhosted.org/packages/e0/cb/4dee9697891c9c6474b240d00e27688e03ecd882d3c83cc97eb25c2266ff/rasterio-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:a3539a2f401a7b4b2e94ff2db334878c0e15a2d1c9fe90bb0879c52f89367ae5", size = 28589538, upload-time = "2026-01-05T16:06:09.662Z" }, + { url = "https://files.pythonhosted.org/packages/77/9f/f84dfa54110c1c82f9f4fd929465d12519569b6f5d015273aa0957013b2e/rasterio-1.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:597be8df418d5ba7b6a927b6b9febfcb42b192882448a8d5b2e2e75a1296631f", size = 22788832, upload-time = "2026-01-05T16:06:12.247Z" }, + { url = "https://files.pythonhosted.org/packages/20/f1/de55255c918b17afd7292f793a3500c4aea7e9530b2b3f5b3a57836c7d49/rasterio-1.5.0-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:dd292030d39d685c0b35eddef233e7f1cb8b43052578a3ec97a2da57799693be", size = 24405917, upload-time = "2026-01-05T16:06:14.603Z" }, + { url = "https://files.pythonhosted.org/packages/a9/57/054087a9d5011ad5dfa799277ba8814e41775e1967d37a59ab7b8e2f1876/rasterio-1.5.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:62c3f97a3c72643c74f2d0f310621a09c35c0c412229c327ae6bcc1ee4b9c3bc", size = 35987536, upload-time = "2026-01-05T16:06:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/c9/72/5fbe5f67ae75d7e89ffb718c500d5fecbaa84f6ba354db306de689faf961/rasterio-1.5.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:19577f0f0c5f1158af47b57f73356961cbd1782a5f6ae6f3adf6f2650f4eb369", size = 37408048, upload-time = "2026-01-05T16:06:20.82Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3e/0c4ef19980204bdcbc8f9e084056adebc97916ff4edcc718750ef34e5bf9/rasterio-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:015c1ab6e5453312c5e29692752e7ad73568fe4d13567cbd448d7893128cbd2d", size = 30949590, upload-time = "2026-01-05T16:06:23.425Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d8/2e6b81505408926c00e629d7d3d73fd0454213201bd9907450e0fe82f3dd/rasterio-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:ff677c0a9d3ba667c067227ef2b76872488b37ff29b061bc3e576fad9baa3286", size = 29337287, upload-time = "2026-01-05T16:06:26.599Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/7b6e6afb28d4e3f69f2229f990ed87dfdc21a3e15ca63b96b2fd9ba17d89/rasterio-1.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:508251b9c746d8d008771a30c2160ff321bfc3b41f6a1aa8e8ef1dd4a00d97ba", size = 22926149, upload-time = "2026-01-05T16:06:29.617Z" }, + { url = "https://files.pythonhosted.org/packages/24/30/19345d8bc7d2b96c1172594026b9009702e9ab9f0baf07079d3612aaadae/rasterio-1.5.0-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:742841ed48bc70f6ef517b8fa3521f231780bf408fde0aa6d73770337a36374e", size = 24516040, upload-time = "2026-01-05T16:06:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/9e/43/dc7a4518fa78904bc41952cbf346c3c2a88a20e61b479154058392914c0b/rasterio-1.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c9a9eee49ce9410c2f352b34c370bb3a96bb518b6a7f97b3a72ee4c835fd4b5c", size = 36589519, upload-time = "2026-01-05T16:06:35.922Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/8f706083c6c163054d12c7ed6d5ac4e4ed02252b761288d74e6158871b34/rasterio-1.5.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b9fd87a0b63ab5c6267dfb0bc96f54fdf49d000651b9ee85ed37798141cff046", size = 37714599, upload-time = "2026-01-05T16:06:38.818Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d5/bbca726d5fea5864f7e4bcf3ee893095369e93ad51120495e8c40e2aa1a0/rasterio-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f459db8953ba30ca04fcef2b5e1260eeeff0eae8158bd9c3d6adbe56289765cc", size = 31233931, upload-time = "2026-01-05T16:06:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d1/8b017856e63ccaff3cbd0e82490dbb01363a42f3a462a41b1d8a391e1443/rasterio-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f4b9c2c3b5f10469eb9588f105086e68f0279e62cc9095c4edd245e3f9b88c8a", size = 29418321, upload-time = "2026-01-05T16:06:44.758Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.2.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/c0/d8079d4f6342e4cec5c3e7d7415b5cd3e633d5f4124f7a4626908dbe84c7/regex-2026.2.19.tar.gz", hash = "sha256:6fb8cb09b10e38f3ae17cc6dc04a1df77762bd0351b6ba9041438e7cc85ec310", size = 414973, upload-time = "2026-02-19T19:03:47.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/2d/a849835e76ac88fcf9e8784e642d3ea635d183c4112150ca91499d6703af/regex-2026.2.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8df08decd339e8b3f6a2eb5c05c687fe9d963ae91f352bc57beb05f5b2ac6879", size = 489329, upload-time = "2026-02-19T19:01:23.841Z" }, + { url = "https://files.pythonhosted.org/packages/da/aa/78ff4666d3855490bae87845a5983485e765e1f970da20adffa2937b241d/regex-2026.2.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3aa0944f1dc6e92f91f3b306ba7f851e1009398c84bfd370633182ee4fc26a64", size = 291308, upload-time = "2026-02-19T19:01:25.605Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/714384efcc07ae6beba528a541f6e99188c5cc1bc0295337f4e8a868296d/regex-2026.2.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c13228fbecb03eadbfd8f521732c5fda09ef761af02e920a3148e18ad0e09968", size = 289033, upload-time = "2026-02-19T19:01:27.243Z" }, + { url = "https://files.pythonhosted.org/packages/75/ec/6438a9344d2869cf5265236a06af1ca6d885e5848b6561e10629bc8e5a11/regex-2026.2.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d0e72703c60d68b18b27cde7cdb65ed2570ae29fb37231aa3076bfb6b1d1c13", size = 798798, upload-time = "2026-02-19T19:01:28.877Z" }, + { url = "https://files.pythonhosted.org/packages/c2/be/b1ce2d395e3fd2ce5f2fde2522f76cade4297cfe84cd61990ff48308749c/regex-2026.2.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46e69a4bf552e30e74a8aa73f473c87efcb7f6e8c8ece60d9fd7bf13d5c86f02", size = 864444, upload-time = "2026-02-19T19:01:30.933Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/a3406460c504f7136f140d9461960c25f058b0240e4424d6fb73c7a067ab/regex-2026.2.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8edda06079bd770f7f0cf7f3bba1a0b447b96b4a543c91fe0c142d034c166161", size = 912633, upload-time = "2026-02-19T19:01:32.744Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d9/e5dbef95008d84e9af1dc0faabbc34a7fbc8daa05bc5807c5cf86c2bec49/regex-2026.2.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cbc69eae834afbf634f7c902fc72ff3e993f1c699156dd1af1adab5d06b7fe7", size = 803718, upload-time = "2026-02-19T19:01:34.61Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e5/61d80132690a1ef8dc48e0f44248036877aebf94235d43f63a20d1598888/regex-2026.2.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bcf57d30659996ee5c7937999874504c11b5a068edc9515e6a59221cc2744dd1", size = 775975, upload-time = "2026-02-19T19:01:36.525Z" }, + { url = "https://files.pythonhosted.org/packages/05/32/ae828b3b312c972cf228b634447de27237d593d61505e6ad84723f8eabba/regex-2026.2.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8e6e77cd92216eb489e21e5652a11b186afe9bdefca8a2db739fd6b205a9e0a4", size = 788129, upload-time = "2026-02-19T19:01:38.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/25/d74f34676f22bec401eddf0e5e457296941e10cbb2a49a571ca7a2c16e5a/regex-2026.2.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b9ab8dec42afefa6314ea9b31b188259ffdd93f433d77cad454cd0b8d235ce1c", size = 858818, upload-time = "2026-02-19T19:01:40.409Z" }, + { url = "https://files.pythonhosted.org/packages/1e/eb/0bc2b01a6b0b264e1406e5ef11cae3f634c3bd1a6e61206fd3227ce8e89c/regex-2026.2.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:294c0fb2e87c6bcc5f577c8f609210f5700b993151913352ed6c6af42f30f95f", size = 764186, upload-time = "2026-02-19T19:01:43.009Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/5fe5a630d0d99ecf0c3570f8905dafbc160443a2d80181607770086c9812/regex-2026.2.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c0924c64b082d4512b923ac016d6e1dcf647a3560b8a4c7e55cbbd13656cb4ed", size = 850363, upload-time = "2026-02-19T19:01:45.015Z" }, + { url = "https://files.pythonhosted.org/packages/c3/45/ef68d805294b01ec030cfd388724ba76a5a21a67f32af05b17924520cb0b/regex-2026.2.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:790dbf87b0361606cb0d79b393c3e8f4436a14ee56568a7463014565d97da02a", size = 790026, upload-time = "2026-02-19T19:01:47.51Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/40d3b66923dfc5aeba182f194f0ca35d09afe8c031a193e6ae46971a0a0e/regex-2026.2.19-cp313-cp313-win32.whl", hash = "sha256:43cdde87006271be6963896ed816733b10967baaf0e271d529c82e93da66675b", size = 266372, upload-time = "2026-02-19T19:01:49.469Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f2/39082e8739bfd553497689e74f9d5e5bb531d6f8936d0b94f43e18f219c0/regex-2026.2.19-cp313-cp313-win_amd64.whl", hash = "sha256:127ea69273485348a126ebbf3d6052604d3c7da284f797bba781f364c0947d47", size = 277253, upload-time = "2026-02-19T19:01:51.208Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c2/852b9600d53fb47e47080c203e2cdc0ac7e84e37032a57e0eaa37446033a/regex-2026.2.19-cp313-cp313-win_arm64.whl", hash = "sha256:5e56c669535ac59cbf96ca1ece0ef26cb66809990cda4fa45e1e32c3b146599e", size = 270505, upload-time = "2026-02-19T19:01:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a2/e0b4575b93bc84db3b1fab24183e008691cd2db5c0ef14ed52681fbd94dd/regex-2026.2.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:93d881cab5afdc41a005dba1524a40947d6f7a525057aa64aaf16065cf62faa9", size = 492202, upload-time = "2026-02-19T19:01:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/24/b5/b84fec8cbb5f92a7eed2b6b5353a6a9eed9670fee31817c2da9eb85dc797/regex-2026.2.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:80caaa1ddcc942ec7be18427354f9d58a79cee82dea2a6b3d4fd83302e1240d7", size = 292884, upload-time = "2026-02-19T19:01:58.254Z" }, + { url = "https://files.pythonhosted.org/packages/70/0c/fe89966dfae43da46f475362401f03e4d7dc3a3c955b54f632abc52669e0/regex-2026.2.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d793c5b4d2b4c668524cd1651404cfc798d40694c759aec997e196fe9729ec60", size = 291236, upload-time = "2026-02-19T19:01:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f7/bda2695134f3e63eb5cccbbf608c2a12aab93d261ff4e2fe49b47fabc948/regex-2026.2.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5100acb20648d9efd3f4e7e91f51187f95f22a741dcd719548a6cf4e1b34b3f", size = 807660, upload-time = "2026-02-19T19:02:01.632Z" }, + { url = "https://files.pythonhosted.org/packages/11/56/6e3a4bf5e60d17326b7003d91bbde8938e439256dec211d835597a44972d/regex-2026.2.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e3a31e94d10e52a896adaa3adf3621bd526ad2b45b8c2d23d1bbe74c7423007", size = 873585, upload-time = "2026-02-19T19:02:03.522Z" }, + { url = "https://files.pythonhosted.org/packages/35/5e/c90c6aa4d1317cc11839359479cfdd2662608f339e84e81ba751c8a4e461/regex-2026.2.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8497421099b981f67c99eba4154cf0dfd8e47159431427a11cfb6487f7791d9e", size = 915243, upload-time = "2026-02-19T19:02:05.608Z" }, + { url = "https://files.pythonhosted.org/packages/90/7c/981ea0694116793001496aaf9524e5c99e122ec3952d9e7f1878af3a6bf1/regex-2026.2.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e7a08622f7d51d7a068f7e4052a38739c412a3e74f55817073d2e2418149619", size = 812922, upload-time = "2026-02-19T19:02:08.115Z" }, + { url = "https://files.pythonhosted.org/packages/2d/be/9eda82afa425370ffdb3fa9f3ea42450b9ae4da3ff0a4ec20466f69e371b/regex-2026.2.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8abe671cf0f15c26b1ad389bf4043b068ce7d3b1c5d9313e12895f57d6738555", size = 781318, upload-time = "2026-02-19T19:02:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d5/50f0bbe56a8199f60a7b6c714e06e54b76b33d31806a69d0703b23ce2a9e/regex-2026.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5a8f28dd32a4ce9c41758d43b5b9115c1c497b4b1f50c457602c1d571fa98ce1", size = 795649, upload-time = "2026-02-19T19:02:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/d039f081e44a8b0134d0bb2dd805b0ddf390b69d0b58297ae098847c572f/regex-2026.2.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:654dc41a5ba9b8cc8432b3f1aa8906d8b45f3e9502442a07c2f27f6c63f85db5", size = 868844, upload-time = "2026-02-19T19:02:14.043Z" }, + { url = "https://files.pythonhosted.org/packages/ef/53/e2903b79a19ec8557fe7cd21cd093956ff2dbc2e0e33969e3adbe5b184dd/regex-2026.2.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4a02faea614e7fdd6ba8b3bec6c8e79529d356b100381cec76e638f45d12ca04", size = 770113, upload-time = "2026-02-19T19:02:16.161Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e2/784667767b55714ebb4e59bf106362327476b882c0b2f93c25e84cc99b1a/regex-2026.2.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d96162140bb819814428800934c7b71b7bffe81fb6da2d6abc1dcca31741eca3", size = 854922, upload-time = "2026-02-19T19:02:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/59/78/9ef4356bd4aed752775bd18071034979b85f035fec51f3a4f9dea497a254/regex-2026.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c227f2922153ee42bbeb355fd6d009f8c81d9d7bdd666e2276ce41f53ed9a743", size = 799636, upload-time = "2026-02-19T19:02:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/cf/54/fcfc9287f20c5c9bd8db755aafe3e8cf4d99a6a3f1c7162ee182e0ca9374/regex-2026.2.19-cp313-cp313t-win32.whl", hash = "sha256:a178df8ec03011153fbcd2c70cb961bc98cbbd9694b28f706c318bee8927c3db", size = 268968, upload-time = "2026-02-19T19:02:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a0/ff24c6cb1273e42472706d277147fc38e1f9074a280fb6034b0fc9b69415/regex-2026.2.19-cp313-cp313t-win_amd64.whl", hash = "sha256:2c1693ca6f444d554aa246b592355b5cec030ace5a2729eae1b04ab6e853e768", size = 280390, upload-time = "2026-02-19T19:02:25.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/a3f6ad89d780ffdeebb4d5e2e3e30bd2ef1f70f6a94d1760e03dd1e12c60/regex-2026.2.19-cp313-cp313t-win_arm64.whl", hash = "sha256:c0761d7ae8d65773e01515ebb0b304df1bf37a0a79546caad9cbe79a42c12af7", size = 271643, upload-time = "2026-02-19T19:02:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e2/7ad4e76a6dddefc0d64dbe12a4d3ca3947a19ddc501f864a5df2a8222ddd/regex-2026.2.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:03d191a9bcf94d31af56d2575210cb0d0c6a054dbcad2ea9e00aa4c42903b919", size = 489306, upload-time = "2026-02-19T19:02:29.058Z" }, + { url = "https://files.pythonhosted.org/packages/14/95/ee1736135733afbcf1846c58671046f99c4d5170102a150ebb3dd8d701d9/regex-2026.2.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:516ee067c6c721d0d0bfb80a2004edbd060fffd07e456d4e1669e38fe82f922e", size = 291218, upload-time = "2026-02-19T19:02:31.083Z" }, + { url = "https://files.pythonhosted.org/packages/ef/08/180d1826c3d7065200a5168c6b993a44947395c7bb6e04b2c2a219c34225/regex-2026.2.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:997862c619994c4a356cb7c3592502cbd50c2ab98da5f61c5c871f10f22de7e5", size = 289097, upload-time = "2026-02-19T19:02:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/28/93/0651924c390c5740f5f896723f8ddd946a6c63083a7d8647231c343912ff/regex-2026.2.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b9e1b8a7ebe2807cd7bbdf662510c8e43053a23262b9f46ad4fc2dfc9d204e", size = 799147, upload-time = "2026-02-19T19:02:35.669Z" }, + { url = "https://files.pythonhosted.org/packages/a7/00/2078bd8bcd37d58a756989adbfd9f1d0151b7ca4085a9c2a07e917fbac61/regex-2026.2.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c8fb3b19652e425ff24169dad3ee07f99afa7996caa9dfbb3a9106cd726f49a", size = 865239, upload-time = "2026-02-19T19:02:38.012Z" }, + { url = "https://files.pythonhosted.org/packages/2a/13/75195161ec16936b35a365fa8c1dd2ab29fd910dd2587765062b174d8cfc/regex-2026.2.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50f1ee9488dd7a9fda850ec7c68cad7a32fa49fd19733f5403a3f92b451dcf73", size = 911904, upload-time = "2026-02-19T19:02:40.737Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/ac42f6012179343d1c4bd0ffee8c948d841cb32ea188d37e96d80527fcc9/regex-2026.2.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab780092b1424d13200aa5a62996e95f65ee3db8509be366437439cdc0af1a9f", size = 803518, upload-time = "2026-02-19T19:02:42.923Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/75a08e2269b007b9783f0f86aa64488e023141219cb5f14dc1e69cda56c6/regex-2026.2.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17648e1a88e72d88641b12635e70e6c71c5136ba14edba29bf8fc6834005a265", size = 775866, upload-time = "2026-02-19T19:02:45.189Z" }, + { url = "https://files.pythonhosted.org/packages/92/41/70e7d05faf6994c2ca7a9fcaa536da8f8e4031d45b0ec04b57040ede201f/regex-2026.2.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f914ae8c804c8a8a562fe216100bc156bfb51338c1f8d55fe32cf407774359a", size = 788224, upload-time = "2026-02-19T19:02:47.804Z" }, + { url = "https://files.pythonhosted.org/packages/c8/83/34a2dd601f9deb13c20545c674a55f4a05c90869ab73d985b74d639bac43/regex-2026.2.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c7e121a918bbee3f12ac300ce0a0d2f2c979cf208fb071ed8df5a6323281915c", size = 859682, upload-time = "2026-02-19T19:02:50.583Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/136db9a09a7f222d6e48b806f3730e7af6499a8cad9c72ac0d49d52c746e/regex-2026.2.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2fedd459c791da24914ecc474feecd94cf7845efb262ac3134fe27cbd7eda799", size = 764223, upload-time = "2026-02-19T19:02:52.777Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/bb947743c78a16df481fa0635c50aa1a439bb80b0e6dc24cd4e49c716679/regex-2026.2.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ea8dfc99689240e61fb21b5fc2828f68b90abf7777d057b62d3166b7c1543c4c", size = 850101, upload-time = "2026-02-19T19:02:55.87Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/e3bfe6e97a99f7393665926be02fef772da7f8aa59e50bc3134e4262a032/regex-2026.2.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fff45852160960f29e184ec8a5be5ab4063cfd0b168d439d1fc4ac3744bf29e", size = 789904, upload-time = "2026-02-19T19:02:58.523Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/7e2be6f00cea59d08761b027ad237002e90cac74b1607200ebaa2ba3d586/regex-2026.2.19-cp314-cp314-win32.whl", hash = "sha256:5390b130cce14a7d1db226a3896273b7b35be10af35e69f1cca843b6e5d2bb2d", size = 271784, upload-time = "2026-02-19T19:03:00.418Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f6/639911530335773e7ec60bcaa519557b719586024c1d7eaad1daf87b646b/regex-2026.2.19-cp314-cp314-win_amd64.whl", hash = "sha256:e581f75d5c0b15669139ca1c2d3e23a65bb90e3c06ba9d9ea194c377c726a904", size = 280506, upload-time = "2026-02-19T19:03:02.302Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ec/2582b56b4e036d46bb9b5d74a18548439ffa16c11cf59076419174d80f48/regex-2026.2.19-cp314-cp314-win_arm64.whl", hash = "sha256:7187fdee1be0896c1499a991e9bf7c78e4b56b7863e7405d7bb687888ac10c4b", size = 273557, upload-time = "2026-02-19T19:03:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/49/0b/f901cfeb4efd83e4f5c3e9f91a6de77e8e5ceb18555698aca3a27e215ed3/regex-2026.2.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5ec1d7c080832fdd4e150c6f5621fe674c70c63b3ae5a4454cebd7796263b175", size = 492196, upload-time = "2026-02-19T19:03:08.188Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/349b959e3da874e15eda853755567b4cde7e5309dbb1e07bfe910cfde452/regex-2026.2.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8457c1bc10ee9b29cdfd897ccda41dce6bde0e9abd514bcfef7bcd05e254d411", size = 292878, upload-time = "2026-02-19T19:03:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/98/b0/9d81b3c2c5ddff428f8c506713737278979a2c476f6e3675a9c51da0c389/regex-2026.2.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cce8027010d1ffa3eb89a0b19621cdc78ae548ea2b49fea1f7bfb3ea77064c2b", size = 291235, upload-time = "2026-02-19T19:03:12.5Z" }, + { url = "https://files.pythonhosted.org/packages/04/e7/be7818df8691dbe9508c381ea2cc4c1153e4fdb1c4b06388abeaa93bd712/regex-2026.2.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11c138febb40546ff9e026dbbc41dc9fb8b29e61013fa5848ccfe045f5b23b83", size = 807893, upload-time = "2026-02-19T19:03:15.064Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b6/b898a8b983190cfa0276031c17beb73cfd1db07c03c8c37f606d80b655e2/regex-2026.2.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:74ff212aa61532246bb3036b3dfea62233414b0154b8bc3676975da78383cac3", size = 873696, upload-time = "2026-02-19T19:03:17.848Z" }, + { url = "https://files.pythonhosted.org/packages/1a/98/126ba671d54f19080ec87cad228fb4f3cc387fff8c4a01cb4e93f4ff9d94/regex-2026.2.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d00c95a2b6bfeb3ea1cb68d1751b1dfce2b05adc2a72c488d77a780db06ab867", size = 915493, upload-time = "2026-02-19T19:03:20.343Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/550c84a1a1a7371867fe8be2bea7df55e797cbca4709974811410e195c5d/regex-2026.2.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311fcccb76af31be4c588d5a17f8f1a059ae8f4b097192896ebffc95612f223a", size = 813094, upload-time = "2026-02-19T19:03:23.287Z" }, + { url = "https://files.pythonhosted.org/packages/29/fb/ba221d2fc76a27b6b7d7a60f73a7a6a7bac21c6ba95616a08be2bcb434b0/regex-2026.2.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77cfd6b5e7c4e8bf7a39d243ea05882acf5e3c7002b0ef4756de6606893b0ecd", size = 781583, upload-time = "2026-02-19T19:03:26.872Z" }, + { url = "https://files.pythonhosted.org/packages/26/f1/af79231301297c9e962679efc04a31361b58dc62dec1fc0cb4b8dd95956a/regex-2026.2.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6380f29ff212ec922b6efb56100c089251940e0526a0d05aa7c2d9b571ddf2fe", size = 795875, upload-time = "2026-02-19T19:03:29.223Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/1e1d76cb0a2d0a4f38a039993e1c5cd971ae50435d751c5bae4f10e1c302/regex-2026.2.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:655f553a1fa3ab8a7fd570eca793408b8d26a80bfd89ed24d116baaf13a38969", size = 868916, upload-time = "2026-02-19T19:03:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/a1c01da76dbcfed690855a284c665cc0a370e7d02d1bd635cf9ff7dd74b8/regex-2026.2.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:015088b8558502f1f0bccd58754835aa154a7a5b0bd9d4c9b7b96ff4ae9ba876", size = 770386, upload-time = "2026-02-19T19:03:33.972Z" }, + { url = "https://files.pythonhosted.org/packages/49/6f/94842bf294f432ff3836bfd91032e2ecabea6d284227f12d1f935318c9c4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9e6693b8567a59459b5dda19104c4a4dbbd4a1c78833eacc758796f2cfef1854", size = 855007, upload-time = "2026-02-19T19:03:36.238Z" }, + { url = "https://files.pythonhosted.org/packages/ff/93/393cd203ca0d1d368f05ce12d2c7e91a324bc93c240db2e6d5ada05835f4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4071209fd4376ab5ceec72ad3507e9d3517c59e38a889079b98916477a871868", size = 799863, upload-time = "2026-02-19T19:03:38.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/35afda99bd92bf1a5831e55a4936d37ea4bed6e34c176a3c2238317faf4f/regex-2026.2.19-cp314-cp314t-win32.whl", hash = "sha256:2905ff4a97fad42f2d0834d8b1ea3c2f856ec209837e458d71a061a7d05f9f01", size = 274742, upload-time = "2026-02-19T19:03:40.804Z" }, + { url = "https://files.pythonhosted.org/packages/ae/42/7edc3344dcc87b698e9755f7f685d463852d481302539dae07135202d3ca/regex-2026.2.19-cp314-cp314t-win_amd64.whl", hash = "sha256:64128549b600987e0f335c2365879895f860a9161f283b14207c800a6ed623d3", size = 284443, upload-time = "2026-02-19T19:03:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/3a/45/affdf2d851b42adf3d13fc5b3b059372e9bd299371fd84cf5723c45871fa/regex-2026.2.19-cp314-cp314t-win_arm64.whl", hash = "sha256:a09ae430e94c049dc6957f6baa35ee3418a3a77f3c12b6e02883bd80a2b679b0", size = 274932, upload-time = "2026-02-19T19:03:45.488Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -2249,6 +2540,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + [[package]] name = "rsa" version = "4.9.1" @@ -2464,6 +2821,15 @@ i18n = [ { name = "babel" }, ] +[[package]] +name = "tinydb" +version = "4.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/79/4af51e2bb214b6ea58f857c51183d92beba85b23f7ba61c983ab3de56c33/tinydb-4.8.2.tar.gz", hash = "sha256:f7dfc39b8d7fda7a1ca62a8dbb449ffd340a117c1206b68c50b1a481fb95181d", size = 32566, upload-time = "2024-10-12T15:24:01.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/17/853354204e1ca022d6b7d011ca7f3206c4f8faa3cc743e92609b49c1d83f/tinydb-4.8.2-py3-none-any.whl", hash = "sha256:f97030ee5cbc91eeadd1d7af07ab0e48ceb04aa63d4a983adbaca4cba16e86c3", size = 24888, upload-time = "2024-10-12T15:23:59.833Z" }, +] + [[package]] name = "typer" version = "0.23.1" @@ -2518,6 +2884,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -2563,6 +2941,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/c6/f8f28009920a736d0df434b52e9feebfb4d702ba942f15338cb4a83eafc1/virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", size = 6057761, upload-time = "2025-07-21T04:09:48.059Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, +] + [[package]] name = "yarl" version = "1.22.0"