From 0bc6f263fa4c32e8cf2b8497f84abe7503521480 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Wed, 15 Jul 2026 13:57:50 -0600
Subject: [PATCH 001/151] fix(tests): remove hardcoded group id assumption
test_get_project_area assumed its "Test Group Foo" fixture would always get database id 1, which only held because nothing else in the suite created a Group row first. Any earlier-running test that inserts a Group (e.g. a data-migration test) shifts the sequence and breaks this test with an unrelated 404.
Use the fixture's actual group.id instead of the literal 1.
---
tests/test_geospatial.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/test_geospatial.py b/tests/test_geospatial.py
index a25ce24cb..21608e80d 100644
--- a/tests/test_geospatial.py
+++ b/tests/test_geospatial.py
@@ -117,7 +117,7 @@ def populate():
session.add(group)
session.commit()
- yield
+ yield group
# Cleanup
session.delete(loc1)
@@ -128,15 +128,15 @@ def populate():
session.commit()
-def test_get_project_area():
- response = client.get("/geospatial/project-area/1")
+def test_get_project_area(populate):
+ response = client.get(f"/geospatial/project-area/{populate.id}")
assert response.status_code == 200
data = response.json()
assert "type" in data
assert data["type"] == "FeatureCollection"
assert "features" in data
assert len(data["features"]) > 0
- assert data["features"][0]["properties"]["group_id"] == 1
+ assert data["features"][0]["properties"]["group_id"] == populate.id
assert data["features"][0]["properties"]["group_name"] == "Test Group Foo"
assert (
data["features"][0]["properties"]["group_description"]
From 0bf9e8ce801cb65140eb415306643cb7e7c8ef90 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Wed, 15 Jul 2026 14:12:54 -0600
Subject: [PATCH 002/151] fix(build): package data_migrations with the app
pyproject.toml's explicit setuptools packages list omitted data_migrations, so the installed `oco` CLI couldn't import it at all -- `oco data-migrations status/run` failed with ModuleNotFoundError for every migration, not just new ones. Running via `uv run pytest`/`python -c` masked this because pytest prepends the repo root to sys.path; the installed console-script entry point does not.
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 11ff4d3f7..730f89e31 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -108,7 +108,7 @@ dependencies = [
package = true
[tool.setuptools]
-packages = ["alembic", "cli", "core", "db", "schemas", "services", "transfers"]
+packages = ["alembic", "cli", "core", "data_migrations", "db", "schemas", "services", "transfers"]
[project.scripts]
oco = "cli.cli:cli"
From ffdfe1843a430ea6d51580e2009ada4de5d0f5d6 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Wed, 15 Jul 2026 14:21:06 -0600
Subject: [PATCH 003/151] test(fixtures): default shared fixtures to public
The upcoming ogc_* view filter excludes non-public records, and nine currently-passing tests in test_ogc.py assert that rows backed by the shared location/water_well_thing/group fixtures ARE present in ogc_* responses. Their "draft" default was an arbitrary safe value, not a deliberate test input -- confirmed by grepping the suite for anything that depends on it being specifically "draft".
Flips the three fixtures plus one inline Group(...)construction in test_ogc.py that bypassed the fixture.
---
tests/conftest.py | 6 +++---
tests/test_ogc.py | 4 ++--
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/tests/conftest.py b/tests/conftest.py
index 9eb1afd15..b37962bec 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -134,7 +134,7 @@ def location():
point="POINT(-107.949533 33.809665)",
elevation=2464.9,
county="Sierra",
- release_status="draft",
+ release_status="public",
state="NM",
quad_name="Hillsboro Peak",
)
@@ -182,7 +182,7 @@ def water_well_thing(location):
name="Test Well",
first_visit_date="2023-03-03",
thing_type="water well",
- release_status="draft",
+ release_status="public",
well_depth=10,
hole_depth=10,
well_casing_diameter=5.0,
@@ -1041,7 +1041,7 @@ def observation_to_delete(water_chemistry_sample, sensor):
def group(water_well_thing):
with session_ctx() as session:
group = Group(
- release_status="draft",
+ release_status="public",
name="Test Group",
description="This is a test group.",
project_area="MULTIPOLYGON(((-107.2 33.6, -106.6 33.6, -106.6 34.2, -107.2 34.2, -107.2 33.6)))",
diff --git a/tests/test_ogc.py b/tests/test_ogc.py
index 42a0c9843..f711b9caa 100644
--- a/tests/test_ogc.py
+++ b/tests/test_ogc.py
@@ -413,7 +413,7 @@ def test_ogc_actively_monitored_wells_exposes_water_level_network_group_wells(
group = Group(
name="Water Level Network",
group_type="Monitoring Plan",
- release_status="draft",
+ release_status="public",
)
session.add(group)
session.flush()
@@ -462,7 +462,7 @@ def test_ogc_actively_monitored_wells_excludes_latest_not_currently_monitored(
group = Group(
name="Water Level Network",
group_type="Monitoring Plan",
- release_status="draft",
+ release_status="public",
)
session.add(group)
session.flush()
From 04e259cf25d9298fa8335965b76a907bba1ab074 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Wed, 15 Jul 2026 14:30:57 -0600
Subject: [PATCH 004/151] feat(ogc): filter ogc_* views to public records
Every ogc_* view/materialized view selected release_status but never filtered on it, so the unauthenticated /ogcapi endpoints have been serving private and draft records alongside public ones (e.g. water_wells: 1,145 private + 140 draft rows next to 8,678 public).
Adds "AND release_status = 'public'" to all 21 existing ogc_* relations and introduces ogc_locations (previously the locations collection pointed straight at the raw location table, which has no filter at all). ogc_actively_monitored_wells gets no predicate of its own -- it already inherits public-only rows transitively through its join to ogc_water_well_summary; adding a redundant filter on status_history.release_status would repeat a mistake already made and reverted once (see w1x2y3z4a5b6), since that column is never populated by the transfer scripts.
Fully reversible: downgrade() rebuilds the same relations with the byte-identical unfiltered SQL that was in production before this migration (ogc_locations is the exception, since it didn't exist pre-migration -- downgrade drops it rather than recreating it unfiltered).
Repoints core/pygeoapi-config.yml's locations provider at the new view in the same commit, since shipping the view without the config change (or vice versa) leaves the collection either unfiltered or broken.
Layer visibility (which collections are published)is unchanged. That's separate, out-of-scope work.
---
...blic_release_status_filter_to_ogc_views.py | 1276 +++++++++++++++++
core/pygeoapi-config.yml | 2 +-
2 files changed, 1277 insertions(+), 1 deletion(-)
create mode 100644 alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py
diff --git a/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py b/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py
new file mode 100644
index 000000000..3cc644393
--- /dev/null
+++ b/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py
@@ -0,0 +1,1276 @@
+"""apply public release_status filter to ogc views
+
+Restricts every ogc_* view/materialized view to release_status = 'public'
+so that published, unauthenticated OGC endpoints (/ogcapi) never expose
+private or draft records. Reversible: downgrade() recreates the same 22
+relations with the byte-identical unfiltered SQL that was in production
+before this migration, so "no predicate" is restored exactly rather than
+approximated.
+
+ogc_actively_monitored_wells gets no predicate of its own -- it inherits
+public-only rows transitively once ogc_water_well_summary is filtered (see
+alembic/versions/w1x2y3z4a5b6_drop_child_release_filters_from_ngwmn_views.py
+for why an extra child-table release_status filter here would be wrong).
+Because it depends on ogc_water_well_summary via a direct JOIN, it must be
+dropped before ogc_water_well_summary and recreated after.
+
+ogc_locations does not exist before this migration -- core/pygeoapi-config.yml
+points the locations collection directly at the raw location table. This
+migration creates ogc_locations for the first time (explicit column list,
+no SELECT *, matching every other view in this file) and a separate change
+repoints that one config line at it. Since ogc_locations never existed
+unfiltered in production, downgrade() drops it rather than recreating an
+unfiltered copy.
+
+Revision ID: f4a5b6c7d8e9
+Revises: y3z4a5b6c7d8
+Create Date: 2026-07-14 00: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 = "f4a5b6c7d8e9"
+down_revision: Union[str, Sequence[str], None] = "y3z4a5b6c7d8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+REQUIRED_TABLES = {
+ "thing",
+ "location",
+ "location_thing_association",
+ "group",
+ "group_thing_association",
+ "status_history",
+ "observation",
+ "sample",
+ "field_activity",
+ "field_event",
+ "data_provenance",
+ "NMA_MajorChemistry",
+ "NMA_Chemistry_SampleInfo",
+ "NMA_MinorTraceChemistry",
+}
+
+LATEST_LOCATION_CTE = """
+SELECT DISTINCT ON (lta.thing_id)
+ lta.thing_id,
+ lta.location_id,
+ lta.effective_start
+FROM location_thing_association AS lta
+WHERE lta.effective_end IS NULL
+ORDER BY lta.thing_id, lta.effective_start DESC
+""".strip()
+
+# The 11 thing-type views still in scope after
+# s4t5u6v7w8x9_drop_unused_well_type_ogc_views.py removed the well-subtype
+# variants (abandoned_wells, artesian_wells, dry_holes, dug_wells,
+# exploration_wells, injection_wells, monitoring_wells, observation_wells,
+# piezometers, production_wells, test_wells).
+THING_VIEWS = [
+ ("water_wells", "water well"),
+ ("springs", "spring"),
+ ("diversions_surface_water", "diversion of surface water, etc."),
+ ("ephemeral_streams", "ephemeral stream"),
+ ("lakes_ponds_reservoirs", "lake, pond or reservoir"),
+ ("meteorological_stations", "meteorological station"),
+ ("other_things", "other"),
+ ("outfalls_wastewater_return_flow", "outfall of wastewater or return flow"),
+ ("perennial_streams", "perennial stream"),
+ ("rock_sample_locations", "rock sample location"),
+ ("soil_gas_sample_locations", "soil gas sample location"),
+]
+
+
+def _safe_view_id(view_id: str) -> str:
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", view_id):
+ raise ValueError(f"Unsafe view id: {view_id!r}")
+ return view_id
+
+
+def _drop_view_or_materialized_view(view_name: str) -> None:
+ # DROP VIEW IF EXISTS / DROP MATERIALIZED VIEW IF EXISTS only suppress
+ # "relation does not exist" -- Postgres still raises WrongObjectType if
+ # the relation exists as the other kind (e.g. DROP VIEW against an
+ # existing materialized view), so the relation's actual kind must be
+ # checked first rather than trying both blindly.
+ bind = op.get_bind()
+ relkind = bind.execute(
+ text("SELECT relkind FROM pg_class WHERE oid = to_regclass(:name)"),
+ {"name": view_name},
+ ).scalar()
+ if relkind == "m":
+ op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {view_name}"))
+ elif relkind == "v":
+ op.execute(text(f"DROP VIEW IF EXISTS {view_name}"))
+
+
+def _check_required_tables() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ existing_tables = set(inspector.get_table_names(schema="public"))
+ missing = REQUIRED_TABLES - existing_tables
+ if missing:
+ raise RuntimeError(
+ "Cannot apply public release_status filter to OGC views. "
+ f"Missing required tables: {', '.join(sorted(missing))}"
+ )
+
+
+def _create_thing_view(view_id: str, thing_type: str, public_only: bool) -> str:
+ safe_view_id = _safe_view_id(view_id)
+ escaped_thing_type = thing_type.replace("'", "''")
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE VIEW ogc_{safe_view_id} AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ )
+ SELECT
+ t.id,
+ t.name,
+ t.first_visit_date,
+ t.nma_pk_welldata,
+ t.well_depth,
+ t.hole_depth,
+ t.well_casing_diameter,
+ t.well_casing_depth,
+ t.well_completion_date,
+ t.well_driller_name,
+ t.well_construction_method,
+ t.well_pump_type,
+ t.well_pump_depth,
+ t.formation_completion_code,
+ t.nma_formation_zone,
+ t.release_status,
+ l.elevation,
+ l.point
+ FROM thing AS t
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE t.thing_type = '{escaped_thing_type}'{release_filter}
+ """
+
+
+def _create_latest_depth_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_latest_depth_to_water_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ ranked_obs AS (
+ SELECT
+ fe.thing_id,
+ o.id AS observation_id,
+ o.observation_datetime,
+ o.value,
+ 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{release_filter}
+ )
+ 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(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_avg_tds_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ tds_obs AS (
+ SELECT
+ csi.thing_id,
+ mc.id AS major_chemistry_id,
+ COALESCE(mc."AnalysisDate", csi."CollectionDate")::date AS observation_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'
+ ){release_filter}
+ )
+ 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.observation_date) AS first_tds_observation_date,
+ MAX(to2.observation_date) AS last_tds_observation_date,
+ 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 _create_latest_tds_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE VIEW ogc_latest_tds_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ tds_obs AS (
+ SELECT
+ csi.thing_id,
+ mc.id AS major_chemistry_id,
+ COALESCE(mc."AnalysisDate", csi."CollectionDate") AS observation_datetime,
+ 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'
+ ){release_filter}
+ ),
+ ranked_tds AS (
+ SELECT
+ to2.thing_id,
+ to2.major_chemistry_id,
+ to2.observation_datetime,
+ to2.sample_value,
+ to2.units,
+ ROW_NUMBER() OVER (
+ PARTITION BY to2.thing_id
+ ORDER BY to2.observation_datetime DESC NULLS LAST, to2.major_chemistry_id DESC
+ ) AS rn
+ FROM tds_obs AS to2
+ )
+ SELECT
+ t.id AS id,
+ t.name,
+ t.thing_type,
+ rt.major_chemistry_id,
+ rt.observation_datetime::date AS latest_tds_observation_date,
+ rt.sample_value AS latest_tds_value,
+ rt.units AS latest_tds_units,
+ l.point
+ FROM ranked_tds AS rt
+ JOIN thing AS t ON t.id = rt.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE rt.rn = 1
+ """
+
+
+def _create_depth_to_water_trend_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_depth_to_water_trend_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ obs AS (
+ SELECT
+ fe.thing_id,
+ o.observation_datetime,
+ (o.value - COALESCE(o.measuring_point_height, 0)) AS depth_to_water_bgs
+ FROM observation AS o
+ JOIN sample AS s ON s.id = o.sample_id
+ JOIN field_activity AS fa ON fa.id = s.field_activity_id
+ JOIN field_event AS fe ON fe.id = fa.field_event_id
+ JOIN thing AS t ON t.id = fe.thing_id
+ WHERE
+ t.thing_type = 'water well'
+ AND fa.activity_type = 'groundwater level'
+ AND o.value IS NOT NULL
+ AND o.observation_datetime IS NOT NULL{release_filter}
+ ),
+ agg AS (
+ SELECT
+ ob.thing_id,
+ COUNT(*)::integer AS record_count,
+ MIN(ob.observation_datetime) AS first_observation_datetime,
+ MAX(ob.observation_datetime) AS last_observation_datetime,
+ EXTRACT(EPOCH FROM (MAX(ob.observation_datetime) - MIN(ob.observation_datetime)))
+ / 31557600.0 AS span_years,
+ REGR_SLOPE(
+ ob.depth_to_water_bgs,
+ EXTRACT(EPOCH FROM ob.observation_datetime)
+ ) * 31557600.0 AS slope_ft_per_year
+ FROM obs AS ob
+ GROUP BY ob.thing_id
+ )
+ SELECT
+ t.id AS id,
+ t.name,
+ t.thing_type,
+ a.record_count,
+ a.first_observation_datetime,
+ a.last_observation_datetime,
+ a.span_years,
+ a.slope_ft_per_year,
+ CASE
+ WHEN a.record_count >= 10 OR (a.record_count >= 4 AND a.span_years >= 2.0) THEN
+ CASE
+ WHEN a.slope_ft_per_year IS NULL THEN 'not enough data'
+ WHEN a.slope_ft_per_year > 0.25 THEN 'increasing'
+ WHEN a.slope_ft_per_year < -0.25 THEN 'decreasing'
+ ELSE 'stable'
+ END
+ ELSE 'not enough data'
+ END AS trend_category,
+ l.point
+ FROM agg AS a
+ JOIN thing AS t ON t.id = a.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ """
+
+
+def _create_water_well_summary_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_water_well_summary AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ wl_obs AS (
+ SELECT
+ fe.thing_id,
+ o.id AS observation_id,
+ o.observation_datetime,
+ (o.value - COALESCE(o.measuring_point_height, 0)) AS water_level
+ FROM observation AS o
+ JOIN sample AS s ON s.id = o.sample_id
+ JOIN field_activity AS fa ON fa.id = s.field_activity_id
+ JOIN field_event AS fe ON fe.id = fa.field_event_id
+ JOIN thing AS t ON t.id = fe.thing_id
+ WHERE
+ t.thing_type = 'water well'
+ AND fa.activity_type = 'groundwater level'
+ AND o.value IS NOT NULL
+ AND o.observation_datetime IS NOT NULL{release_filter}
+ ),
+ wl_agg AS (
+ SELECT
+ w.thing_id,
+ COUNT(*)::integer AS total_water_levels,
+ MIN(w.water_level) AS min_water_level,
+ MAX(w.water_level) AS max_water_level,
+ REGR_SLOPE(
+ w.water_level,
+ EXTRACT(EPOCH FROM w.observation_datetime)
+ ) * 31557600.0 AS water_level_trend_ft_per_year
+ FROM wl_obs AS w
+ GROUP BY w.thing_id
+ ),
+ wl_last AS (
+ SELECT
+ ranked.thing_id,
+ ranked.water_level AS last_water_level,
+ ranked.observation_datetime AS last_water_level_datetime
+ FROM (
+ SELECT
+ w.thing_id,
+ w.water_level,
+ w.observation_datetime,
+ ROW_NUMBER() OVER (
+ PARTITION BY w.thing_id
+ ORDER BY w.observation_datetime DESC, w.observation_id DESC
+ ) AS rn
+ FROM wl_obs AS w
+ ) AS ranked
+ WHERE ranked.rn = 1
+ )
+ SELECT
+ t.id AS id,
+ t.name,
+ t.well_depth,
+ l.elevation,
+ dpl.collection_method AS elevation_method,
+ t.nma_formation_zone AS formation_zone,
+ wa.total_water_levels,
+ wl.last_water_level,
+ wl.last_water_level_datetime,
+ wa.min_water_level,
+ wa.max_water_level,
+ wa.water_level_trend_ft_per_year,
+ 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
+ JOIN wl_agg AS wa ON wa.thing_id = t.id
+ LEFT JOIN wl_last AS wl ON wl.thing_id = t.id
+ LEFT JOIN LATERAL (
+ SELECT dp.collection_method
+ FROM data_provenance AS dp
+ WHERE
+ dp.target_table = 'location'
+ AND dp.target_id = l.id
+ AND dp.field_name = 'elevation'
+ ORDER BY dp.id DESC
+ LIMIT 1
+ ) AS dpl ON true
+ WHERE t.thing_type = 'water well'
+ AND wa.total_water_levels > 0
+ """
+
+
+# Static analyte columns for major chemistry pivots.
+# Includes aliases observed in current DB values (e.g., Ca(total), IONBAL, TAn, TCat, Na+K).
+STATIC_ANALYTE_COLUMNS_MAJOR: list[tuple[str, str]] = [
+ ("tds", "tds"),
+ ("calcium", "calcium"),
+ ("calcium_total", "calcium_total"),
+ ("magnesium", "magnesium"),
+ ("magnesium_total", "magnesium_total"),
+ ("sodium", "sodium"),
+ ("sodium_total", "sodium_total"),
+ ("potassium", "potassium"),
+ ("potassium_total", "potassium_total"),
+ ("sodium_plus_potassium", "sodium_plus_potassium"),
+ ("bicarbonate", "bicarbonate"),
+ ("carbonate", "carbonate"),
+ ("sulfate", "sulfate"),
+ ("chloride", "chloride"),
+ ("ion_balance", "ion_balance"),
+ ("total_anions", "total_anions"),
+ ("total_cations", "total_cations"),
+ ("alkalinity", "alkalinity"),
+ ("hardness", "hardness"),
+ ("specific_conductance", "specific_conductance"),
+ ("ph", "ph"),
+ ("nitrate", "nitrate"),
+ ("fluoride", "fluoride"),
+ ("silica", "silica"),
+]
+
+
+def _major_chemistry_select_columns() -> str:
+ return ",\n".join(
+ [
+ (
+ " MAX(lr.sample_value) FILTER "
+ f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}"
+ )
+ for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MAJOR
+ ]
+ )
+
+
+def _major_chemistry_unit_columns() -> str:
+ return ",\n".join(
+ [
+ (
+ " MAX(lr.units) FILTER "
+ f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units"
+ )
+ for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MAJOR
+ ]
+ )
+
+
+def _create_major_chemistry_results_view(public_only: bool) -> str:
+ static_columns = _major_chemistry_select_columns()
+ static_unit_columns = _major_chemistry_unit_columns()
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_major_chemistry_results AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ chemistry_rows AS (
+ SELECT
+ csi.thing_id,
+ mc.id AS result_id,
+ COALESCE(mc."AnalysisDate", csi."CollectionDate") AS observation_datetime,
+ trim(mc."Analyte") AS analyte_name,
+ trim(mc."Symbol") AS symbol_name,
+ mc."SampleValue"::double precision AS sample_value,
+ mc."Units" AS units
+ FROM "NMA_MajorChemistry" AS mc
+ JOIN "NMA_Chemistry_SampleInfo" AS csi
+ ON csi.id = mc.chemistry_sample_info_id
+ JOIN thing AS t
+ ON t.id = csi.thing_id
+ WHERE mc."SampleValue" IS NOT NULL
+ AND t.thing_type = 'water well'{release_filter}
+ ),
+ normalized_rows AS (
+ SELECT
+ cr.thing_id,
+ cr.result_id,
+ cr.observation_datetime,
+ NULLIF(
+ regexp_replace(
+ lower(trim(coalesce(cr.analyte_name, ''))),
+ '[^a-z0-9]+',
+ '',
+ 'g'
+ ),
+ ''
+ ) AS analyte_token,
+ NULLIF(
+ regexp_replace(
+ lower(trim(coalesce(cr.symbol_name, ''))),
+ '[^a-z0-9]+',
+ '',
+ 'g'
+ ),
+ ''
+ ) AS symbol_token,
+ cr.sample_value,
+ cr.units
+ FROM chemistry_rows AS cr
+ ),
+ mapped_rows AS (
+ SELECT
+ nr.thing_id,
+ nr.result_id,
+ nr.observation_datetime,
+ CASE
+ WHEN coalesce(nr.symbol_token, '') = 'tds'
+ OR coalesce(nr.analyte_token, '') IN ('tds', 'totaldissolvedsolids')
+ THEN 'tds'
+
+ WHEN coalesce(nr.symbol_token, '') = 'ca'
+ OR coalesce(nr.analyte_token, '') = 'ca'
+ THEN 'calcium'
+ WHEN coalesce(nr.analyte_token, '') = 'catotal'
+ THEN 'calcium_total'
+
+ WHEN coalesce(nr.symbol_token, '') = 'mg'
+ OR coalesce(nr.analyte_token, '') = 'mg'
+ THEN 'magnesium'
+ WHEN coalesce(nr.analyte_token, '') = 'mgtotal'
+ THEN 'magnesium_total'
+
+ WHEN coalesce(nr.symbol_token, '') = 'na'
+ OR coalesce(nr.analyte_token, '') = 'na'
+ THEN 'sodium'
+ WHEN coalesce(nr.analyte_token, '') = 'natotal'
+ THEN 'sodium_total'
+
+ WHEN coalesce(nr.symbol_token, '') = 'k'
+ OR coalesce(nr.analyte_token, '') = 'k'
+ THEN 'potassium'
+ WHEN coalesce(nr.analyte_token, '') = 'ktotal'
+ THEN 'potassium_total'
+
+ WHEN coalesce(nr.analyte_token, '') = 'nak'
+ THEN 'sodium_plus_potassium'
+
+ WHEN coalesce(nr.symbol_token, '') = 'hco3'
+ OR coalesce(nr.analyte_token, '') = 'hco3'
+ THEN 'bicarbonate'
+ WHEN coalesce(nr.symbol_token, '') = 'co3'
+ OR coalesce(nr.analyte_token, '') = 'co3'
+ THEN 'carbonate'
+ WHEN coalesce(nr.symbol_token, '') = 'so4'
+ OR coalesce(nr.analyte_token, '') = 'so4'
+ THEN 'sulfate'
+ WHEN coalesce(nr.symbol_token, '') = 'cl'
+ OR coalesce(nr.analyte_token, '') = 'cl'
+ THEN 'chloride'
+
+ WHEN coalesce(nr.analyte_token, '') = 'ionbal'
+ THEN 'ion_balance'
+ WHEN coalesce(nr.analyte_token, '') = 'tan'
+ THEN 'total_anions'
+ WHEN coalesce(nr.analyte_token, '') = 'tcat'
+ THEN 'total_cations'
+
+ WHEN coalesce(nr.analyte_token, '') IN ('alk', 'alkalinity')
+ THEN 'alkalinity'
+ WHEN coalesce(nr.analyte_token, '') IN ('hrd', 'hardness')
+ THEN 'hardness'
+ WHEN coalesce(nr.analyte_token, '') IN (
+ 'condlab',
+ 'specificconductance',
+ 'specificconductivity',
+ 'conductivity'
+ )
+ THEN 'specific_conductance'
+ WHEN coalesce(nr.symbol_token, '') = 'ph'
+ OR coalesce(nr.analyte_token, '') IN ('ph', 'phl')
+ THEN 'ph'
+
+ WHEN coalesce(nr.symbol_token, '') = 'no3'
+ OR coalesce(nr.analyte_token, '') IN ('no3', 'nitrate')
+ THEN 'nitrate'
+ WHEN coalesce(nr.symbol_token, '') = 'f'
+ OR coalesce(nr.analyte_token, '') IN ('f', 'fluoride')
+ THEN 'fluoride'
+ WHEN coalesce(nr.symbol_token, '') = 'sio2'
+ OR coalesce(nr.analyte_token, '') IN ('sio2', 'silica')
+ THEN 'silica'
+
+ ELSE NULL
+ END AS analyte_key,
+ nr.sample_value,
+ nr.units
+ FROM normalized_rows AS nr
+ ),
+ latest_results AS (
+ SELECT
+ mr.thing_id,
+ mr.analyte_key,
+ mr.sample_value,
+ mr.units,
+ mr.observation_datetime,
+ ROW_NUMBER() OVER (
+ PARTITION BY mr.thing_id, mr.analyte_key
+ ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC
+ ) AS rn
+ FROM mapped_rows AS mr
+ WHERE mr.analyte_key IS NOT NULL
+ )
+ SELECT
+ t.id AS id,
+ ll.location_id,
+ t.name,
+ t.thing_type,
+ COUNT(*)::integer AS analyte_count,
+ MAX(lr.observation_datetime::date) AS latest_chemistry_date,
+{static_columns},
+{static_unit_columns},
+ l.point
+ FROM latest_results AS lr
+ JOIN thing AS t ON t.id = lr.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE lr.rn = 1
+ GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point
+ """
+
+
+STATIC_ANALYTE_COLUMNS_MINOR: list[tuple[str, str]] = [
+ ("h2r", "h2r"),
+ ("o18r", "o18r"),
+ ("c13r", "c13r"),
+ ("c14", "c14"),
+ ("c14_years", "c14_years"),
+ ("fluoride", "fluoride"),
+ ("barium", "barium"),
+ ("barium_total", "barium_total"),
+ ("copper", "copper"),
+ ("copper_total", "copper_total"),
+ ("zinc", "zinc"),
+ ("zinc_total", "zinc_total"),
+ ("molybdenum", "molybdenum"),
+ ("molybdenum_total", "molybdenum_total"),
+ ("silica", "silica"),
+ ("silicon", "silicon"),
+ ("silicon_total", "silicon_total"),
+ ("manganese", "manganese"),
+ ("manganese_total", "manganese_total"),
+ ("iron", "iron"),
+ ("iron_total", "iron_total"),
+ ("strontium", "strontium"),
+ ("strontium_total", "strontium_total"),
+ ("chromium", "chromium"),
+ ("chromium_total", "chromium_total"),
+ ("boron", "boron"),
+ ("boron_total", "boron_total"),
+ ("uranium", "uranium"),
+ ("uranium_total", "uranium_total"),
+ ("lithium", "lithium"),
+ ("lithium_total", "lithium_total"),
+ ("silver", "silver"),
+ ("silver_total", "silver_total"),
+ ("antimony", "antimony"),
+ ("antimony_total", "antimony_total"),
+ ("beryllium", "beryllium"),
+ ("beryllium_total", "beryllium_total"),
+ ("lead", "lead"),
+ ("lead_total", "lead_total"),
+ ("thallium", "thallium"),
+ ("thallium_total", "thallium_total"),
+ ("bromide", "bromide"),
+ ("selenium", "selenium"),
+ ("selenium_total", "selenium_total"),
+ ("vanadium", "vanadium"),
+ ("vanadium_total", "vanadium_total"),
+ ("aluminum", "aluminum"),
+ ("aluminum_total", "aluminum_total"),
+ ("arsenic", "arsenic"),
+ ("arsenic_total", "arsenic_total"),
+ ("nickel", "nickel"),
+ ("nickel_total", "nickel_total"),
+ ("cadmium", "cadmium"),
+ ("cadmium_total", "cadmium_total"),
+ ("cobalt", "cobalt"),
+ ("cobalt_total", "cobalt_total"),
+ ("phosphate", "phosphate"),
+ ("nitrite", "nitrite"),
+ ("nitrate", "nitrate"),
+ ("nitrate_as_n", "nitrate_as_n"),
+ ("thorium", "thorium"),
+ ("thorium_total", "thorium_total"),
+ ("tin", "tin"),
+ ("tin_total", "tin_total"),
+ ("mercury", "mercury"),
+ ("mercury_total", "mercury_total"),
+ ("titanium", "titanium"),
+ ("titanium_total", "titanium_total"),
+]
+
+
+def _minor_chemistry_value_columns() -> str:
+ return ",\n".join(
+ [
+ (
+ " MAX(lr.sample_value) FILTER "
+ f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}"
+ )
+ for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MINOR
+ ]
+ )
+
+
+def _minor_chemistry_unit_columns() -> str:
+ return ",\n".join(
+ [
+ (
+ " MAX(lr.units) FILTER "
+ f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units"
+ )
+ for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MINOR
+ ]
+ )
+
+
+def _create_minor_chemistry_wells_view(public_only: bool) -> str:
+ value_columns = _minor_chemistry_value_columns()
+ unit_columns = _minor_chemistry_unit_columns()
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_minor_chemistry_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ chemistry_rows AS (
+ SELECT
+ csi.thing_id,
+ mtc.id AS result_id,
+ COALESCE(mtc.analysis_date::timestamp, csi."CollectionDate") AS observation_datetime,
+ trim(mtc.analyte) AS analyte_name,
+ mtc.sample_value::double precision AS sample_value,
+ mtc.units AS units
+ FROM "NMA_MinorTraceChemistry" AS mtc
+ JOIN "NMA_Chemistry_SampleInfo" AS csi
+ ON csi.id = mtc.chemistry_sample_info_id
+ JOIN thing AS t ON t.id = csi.thing_id
+ WHERE
+ mtc.sample_value IS NOT NULL
+ AND t.thing_type = 'water well'{release_filter}
+ ),
+ normalized_rows AS (
+ SELECT
+ cr.thing_id,
+ cr.result_id,
+ cr.observation_datetime,
+ NULLIF(
+ regexp_replace(
+ lower(trim(coalesce(cr.analyte_name, ''))),
+ '[^a-z0-9]+',
+ '',
+ 'g'
+ ),
+ ''
+ ) AS analyte_token,
+ cr.sample_value,
+ cr.units
+ FROM chemistry_rows AS cr
+ ),
+ mapped_rows AS (
+ SELECT
+ nr.thing_id,
+ nr.result_id,
+ nr.observation_datetime,
+ CASE
+ WHEN coalesce(nr.analyte_token, '') = 'h2r' THEN 'h2r'
+ WHEN coalesce(nr.analyte_token, '') = 'o18r' THEN 'o18r'
+ WHEN coalesce(nr.analyte_token, '') = 'c13r' THEN 'c13r'
+ WHEN coalesce(nr.analyte_token, '') = 'c14' THEN 'c14'
+ WHEN coalesce(nr.analyte_token, '') = 'c14years' THEN 'c14_years'
+
+ WHEN coalesce(nr.analyte_token, '') = 'f' THEN 'fluoride'
+ WHEN coalesce(nr.analyte_token, '') = 'ba' THEN 'barium'
+ WHEN coalesce(nr.analyte_token, '') = 'batotal' THEN 'barium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'cu' THEN 'copper'
+ WHEN coalesce(nr.analyte_token, '') = 'cutotal' THEN 'copper_total'
+ WHEN coalesce(nr.analyte_token, '') = 'zn' THEN 'zinc'
+ WHEN coalesce(nr.analyte_token, '') = 'zntotal' THEN 'zinc_total'
+ WHEN coalesce(nr.analyte_token, '') = 'mo' THEN 'molybdenum'
+ WHEN coalesce(nr.analyte_token, '') = 'mototal' THEN 'molybdenum_total'
+ WHEN coalesce(nr.analyte_token, '') = 'sio2' THEN 'silica'
+ WHEN coalesce(nr.analyte_token, '') = 'si' THEN 'silicon'
+ WHEN coalesce(nr.analyte_token, '') = 'sitotal' THEN 'silicon_total'
+ WHEN coalesce(nr.analyte_token, '') = 'mn' THEN 'manganese'
+ WHEN coalesce(nr.analyte_token, '') = 'mntotal' THEN 'manganese_total'
+ WHEN coalesce(nr.analyte_token, '') = 'fe' THEN 'iron'
+ WHEN coalesce(nr.analyte_token, '') = 'fetotal' THEN 'iron_total'
+ WHEN coalesce(nr.analyte_token, '') = 'sr' THEN 'strontium'
+ WHEN coalesce(nr.analyte_token, '') = 'srtotal' THEN 'strontium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'cr' THEN 'chromium'
+ WHEN coalesce(nr.analyte_token, '') = 'crtotal' THEN 'chromium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'b' THEN 'boron'
+ WHEN coalesce(nr.analyte_token, '') = 'btotal' THEN 'boron_total'
+ WHEN coalesce(nr.analyte_token, '') = 'u' THEN 'uranium'
+ WHEN coalesce(nr.analyte_token, '') = 'utotal' THEN 'uranium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'li' THEN 'lithium'
+ WHEN coalesce(nr.analyte_token, '') = 'litotal' THEN 'lithium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'ag' THEN 'silver'
+ WHEN coalesce(nr.analyte_token, '') = 'agtotal' THEN 'silver_total'
+ WHEN coalesce(nr.analyte_token, '') = 'sb' THEN 'antimony'
+ WHEN coalesce(nr.analyte_token, '') = 'sbtotal' THEN 'antimony_total'
+ WHEN coalesce(nr.analyte_token, '') = 'be' THEN 'beryllium'
+ WHEN coalesce(nr.analyte_token, '') = 'betotal' THEN 'beryllium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'pb' THEN 'lead'
+ WHEN coalesce(nr.analyte_token, '') = 'pbtotal' THEN 'lead_total'
+ WHEN coalesce(nr.analyte_token, '') = 'tl' THEN 'thallium'
+ WHEN coalesce(nr.analyte_token, '') = 'tltotal' THEN 'thallium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'br' THEN 'bromide'
+ WHEN coalesce(nr.analyte_token, '') = 'se' THEN 'selenium'
+ WHEN coalesce(nr.analyte_token, '') = 'setotal' THEN 'selenium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'v' THEN 'vanadium'
+ WHEN coalesce(nr.analyte_token, '') = 'vtotal' THEN 'vanadium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'al' THEN 'aluminum'
+ WHEN coalesce(nr.analyte_token, '') = 'altotal' THEN 'aluminum_total'
+ WHEN coalesce(nr.analyte_token, '') = 'as' THEN 'arsenic'
+ WHEN coalesce(nr.analyte_token, '') = 'astotal' THEN 'arsenic_total'
+ WHEN coalesce(nr.analyte_token, '') = 'ni' THEN 'nickel'
+ WHEN coalesce(nr.analyte_token, '') = 'nitotal' THEN 'nickel_total'
+ WHEN coalesce(nr.analyte_token, '') = 'cd' THEN 'cadmium'
+ WHEN coalesce(nr.analyte_token, '') = 'cdtotal' THEN 'cadmium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'co' THEN 'cobalt'
+ WHEN coalesce(nr.analyte_token, '') = 'cototal' THEN 'cobalt_total'
+ WHEN coalesce(nr.analyte_token, '') = 'po4' THEN 'phosphate'
+ WHEN coalesce(nr.analyte_token, '') = 'no2' THEN 'nitrite'
+ WHEN coalesce(nr.analyte_token, '') = 'no3' THEN 'nitrate'
+ WHEN coalesce(nr.analyte_token, '') = 'no3n' THEN 'nitrate_as_n'
+ WHEN coalesce(nr.analyte_token, '') = 'th' THEN 'thorium'
+ WHEN coalesce(nr.analyte_token, '') = 'thtotal' THEN 'thorium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'sn' THEN 'tin'
+ WHEN coalesce(nr.analyte_token, '') = 'sntotal' THEN 'tin_total'
+ WHEN coalesce(nr.analyte_token, '') = 'hg' THEN 'mercury'
+ WHEN coalesce(nr.analyte_token, '') = 'hgtotal' THEN 'mercury_total'
+ WHEN coalesce(nr.analyte_token, '') = 'ti' THEN 'titanium'
+ WHEN coalesce(nr.analyte_token, '') = 'titotal' THEN 'titanium_total'
+ ELSE NULL
+ END AS analyte_key,
+ nr.sample_value,
+ nr.units
+ FROM normalized_rows AS nr
+ ),
+ latest_results AS (
+ SELECT
+ mr.thing_id,
+ mr.analyte_key,
+ mr.sample_value,
+ mr.units,
+ mr.observation_datetime,
+ ROW_NUMBER() OVER (
+ PARTITION BY mr.thing_id, mr.analyte_key
+ ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC
+ ) AS rn
+ FROM mapped_rows AS mr
+ WHERE mr.analyte_key IS NOT NULL
+ )
+ SELECT
+ t.id AS id,
+ ll.location_id,
+ t.name,
+ t.thing_type,
+ COUNT(*)::integer AS analyte_count,
+ MAX(lr.observation_datetime::date) AS latest_chemistry_date,
+{value_columns},
+{unit_columns},
+ l.point
+ FROM latest_results AS lr
+ JOIN thing AS t ON t.id = lr.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE lr.rn = 1
+ AND t.thing_type = 'water well'
+ GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point
+ """
+
+
+METERS_TO_FEET = 3.28084
+
+
+def _create_water_elevation_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_water_elevation_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ ranked_obs AS (
+ SELECT
+ fe.thing_id,
+ o.id AS observation_id,
+ o.observation_datetime,
+ CASE
+ WHEN lower(trim(o.unit)) IN ('m', 'meter', 'meters', 'metre', 'metres') THEN
+ (o.value * {METERS_TO_FEET}) - COALESCE(o.measuring_point_height, 0)
+ WHEN lower(trim(o.unit)) IN ('ft', 'foot', 'feet') THEN
+ o.value - COALESCE(o.measuring_point_height, 0)
+ ELSE
+ NULL
+ END AS depth_to_water_below_ground_surface
+ FROM observation AS o
+ JOIN sample AS s ON s.id = o.sample_id
+ JOIN field_activity AS fa ON fa.id = s.field_activity_id
+ JOIN field_event AS fe ON fe.id = fa.field_event_id
+ JOIN thing AS t ON t.id = fe.thing_id
+ WHERE
+ t.thing_type = 'water well'
+ AND fa.activity_type = 'groundwater level'
+ AND o.value IS NOT NULL
+ AND o.observation_datetime IS NOT NULL
+ AND lower(trim(o.unit)) IN (
+ 'm',
+ 'meter',
+ 'meters',
+ 'metre',
+ 'metres',
+ 'ft',
+ 'foot',
+ 'feet'
+ ){release_filter}
+ ),
+ latest_obs AS (
+ SELECT
+ ro.*,
+ ROW_NUMBER() OVER (
+ PARTITION BY ro.thing_id
+ ORDER BY ro.observation_datetime DESC, ro.observation_id DESC
+ ) AS rn
+ FROM ranked_obs AS ro
+ )
+ SELECT
+ t.id AS id,
+ t.name,
+ t.thing_type,
+ lo.observation_id,
+ lo.observation_datetime,
+ l.elevation AS elevation_m,
+ lo.depth_to_water_below_ground_surface AS depth_to_water_below_ground_surface_ft,
+ ((l.elevation * {METERS_TO_FEET}) - lo.depth_to_water_below_ground_surface)
+ AS water_elevation_ft,
+ l.point
+ FROM latest_obs AS lo
+ JOIN thing AS t ON t.id = lo.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE lo.rn = 1
+ """
+
+
+def _create_actively_monitored_wells_view() -> str:
+ # No predicate of its own -- inherits public-only rows transitively via
+ # the JOIN to ogc_water_well_summary, which is itself filtered. Adding a
+ # filter on status_history.release_status here would repeat the mistake
+ # reverted in w1x2y3z4a5b6 (that column is never actually populated for
+ # this table).
+ return """
+ CREATE VIEW ogc_actively_monitored_wells AS
+ WITH latest_monitoring_status AS (
+ SELECT DISTINCT ON (sh.target_id)
+ sh.target_id AS thing_id,
+ sh.status_value
+ FROM status_history AS sh
+ WHERE
+ sh.target_table = 'thing'
+ AND sh.status_type = 'Monitoring Status'
+ ORDER BY sh.target_id, sh.start_date DESC, sh.id DESC
+ )
+ SELECT
+ wws.id,
+ wws.name,
+ 'water well'::text AS thing_type,
+ wws.well_depth,
+ wws.elevation,
+ wws.elevation_method,
+ wws.formation_zone,
+ wws.total_water_levels,
+ wws.last_water_level,
+ wws.last_water_level_datetime,
+ wws.min_water_level,
+ wws.max_water_level,
+ wws.water_level_trend_ft_per_year,
+ g.id AS group_id,
+ g.name AS group_name,
+ g.group_type,
+ wws.point
+ FROM "group" AS g
+ JOIN group_thing_association AS gta ON gta.group_id = g.id
+ JOIN ogc_water_well_summary AS wws ON wws.id = gta.thing_id
+ JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id
+ WHERE lower(trim(g.name)) = 'water level network'
+ AND lms.status_value = 'Currently monitored'
+ """
+
+
+def _create_project_areas_view(public_only: bool) -> str:
+ release_filter = " AND g.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE VIEW ogc_project_areas AS
+ SELECT
+ g.id,
+ g.name,
+ g.description,
+ g.group_type,
+ g.release_status,
+ g.project_area
+ FROM "group" AS g
+ WHERE g.project_area IS NOT NULL{release_filter}
+ """
+
+
+def _create_locations_view() -> str:
+ # Explicit column list verified against db/location.py and its mixins
+ # (AutoBaseMixin/AuditMixin, ReleaseMixin, NotesMixin, DataProvenanceMixin).
+ # NotesMixin/DataProvenanceMixin add only polymorphic relationships, no
+ # real columns. Audit columns (created_at, created_by_*, updated_by_*)
+ # are deliberately excluded, matching every other view in this file --
+ # none of them expose those columns either, even for Thing's otherwise
+ # thorough column list.
+ return """
+ CREATE VIEW ogc_locations AS
+ SELECT
+ l.id,
+ l.nma_pk_location,
+ l.description,
+ l.county,
+ l.state,
+ l.quad_name,
+ l.nma_location_notes,
+ l.nma_coordinate_notes,
+ l.nma_data_reliability,
+ l.nma_date_created,
+ l.nma_site_date,
+ l.release_status,
+ l.elevation,
+ l.point
+ FROM location AS l
+ WHERE l.release_status = 'public'
+ """
+
+
+def _recreate_governed_views(public_only: bool) -> None:
+ # ogc_actively_monitored_wells depends on ogc_water_well_summary via a
+ # direct JOIN; Postgres refuses to drop a materialized view while a
+ # dependent view exists, so it must go first and come back last.
+ _drop_view_or_materialized_view("ogc_actively_monitored_wells")
+
+ for view_id, thing_type in THING_VIEWS:
+ _drop_view_or_materialized_view(f"ogc_{_safe_view_id(view_id)}")
+ op.execute(text(_create_thing_view(view_id, thing_type, public_only)))
+
+ _drop_view_or_materialized_view("ogc_latest_depth_to_water_wells")
+ op.execute(text(_create_latest_depth_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_latest_depth_to_water_wells IS "
+ "'Latest depth-to-water per well view for pygeoapi.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_latest_depth_to_water_wells_id "
+ "ON ogc_latest_depth_to_water_wells (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_avg_tds_wells")
+ op.execute(text(_create_avg_tds_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_avg_tds_wells IS "
+ "'Average TDS per well from major chemistry results for pygeoapi.'"
+ )
+ )
+ op.execute(
+ text("CREATE UNIQUE INDEX ux_ogc_avg_tds_wells_id " "ON ogc_avg_tds_wells (id)")
+ )
+
+ _drop_view_or_materialized_view("ogc_latest_tds_wells")
+ op.execute(text(_create_latest_tds_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_latest_tds_wells IS "
+ "'Latest TDS per well from major chemistry results for pygeoapi.'"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_depth_to_water_trend_wells")
+ op.execute(text(_create_depth_to_water_trend_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_depth_to_water_trend_wells IS "
+ "'Depth-to-water trend classification for water wells.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_depth_to_water_trend_wells_id "
+ "ON ogc_depth_to_water_trend_wells (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_water_well_summary")
+ op.execute(text(_create_water_well_summary_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_water_well_summary IS "
+ "'Summary statistics for water wells including water-level trend.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_water_well_summary_id "
+ "ON ogc_water_well_summary (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_major_chemistry_results")
+ op.execute(text(_create_major_chemistry_results_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_major_chemistry_results IS "
+ "'Latest major-chemistry analyte values per location, pivoted into static analyte columns.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_major_chemistry_results_id "
+ "ON ogc_major_chemistry_results (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_minor_chemistry_wells")
+ op.execute(text(_create_minor_chemistry_wells_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_minor_chemistry_wells IS "
+ "'Latest minor/trace chemistry analyte values for water wells, pivoted into static analyte columns.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_minor_chemistry_wells_id "
+ "ON ogc_minor_chemistry_wells (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_water_elevation_wells")
+ op.execute(text(_create_water_elevation_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_water_elevation_wells IS "
+ "'Latest water elevation per well with explicit units: "
+ "elevation_m, depth_to_water_below_ground_surface_ft, water_elevation_ft.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_water_elevation_wells_id "
+ "ON ogc_water_elevation_wells (id)"
+ )
+ )
+
+ # Recreate now that ogc_water_well_summary exists again.
+ op.execute(text(_create_actively_monitored_wells_view()))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_actively_monitored_wells IS "
+ "'Wells in the Water Level Network group for pygeoapi.'"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_project_areas")
+ op.execute(text(_create_project_areas_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_project_areas IS "
+ "'Project areas for groups with polygon boundaries for pygeoapi.'"
+ )
+ )
+
+
+def upgrade() -> None:
+ _check_required_tables()
+ _recreate_governed_views(public_only=True)
+
+ # ogc_locations does not exist before this migration -- see module
+ # docstring. Only ever created in its filtered form.
+ _drop_view_or_materialized_view("ogc_locations")
+ op.execute(text(_create_locations_view()))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_locations IS "
+ "'Public locations for pygeoapi, replacing the raw location table provider.'"
+ )
+ )
+
+
+def downgrade() -> None:
+ _recreate_governed_views(public_only=False)
+
+ # ogc_locations never existed unfiltered in production; downgrading
+ # drops it rather than recreating an unfiltered copy.
+ _drop_view_or_materialized_view("ogc_locations")
diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml
index 45f3bac17..ccae84eab 100644
--- a/core/pygeoapi-config.yml
+++ b/core/pygeoapi-config.yml
@@ -54,7 +54,7 @@ resources:
password: {postgres_password_env}
search_path: [public]
id_field: id
- table: location
+ table: ogc_locations
geom_field: point
latest_depth_to_water_wells:
From c91c8422be7813607da97183000f37811bddfaa4 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Wed, 15 Jul 2026 14:38:02 -0600
Subject: [PATCH 005/151] feat(data-migrations): publish existing project_areas
ogc_project_areas is now filtered to public records, but all 56 current project_area-bearing group rows are release_status=draft (confirmed in docs/ogc-layer-audit.md), which would make the layer return zero rows until someone flips them.
Uses the data_migrations framework (not Alembic, which is schema-only in this repo) so the change is tracked, skip-if-applied, and gated on this ticket's schema revision having landed first. Runs independently via `oco data-migrations run 20260714_0001_publish_project_areas` -- deliberately not swept in via run-all, which could also apply other unrelated pending migrations. No downgrade: this framework is forward-only, and a blanket revert couldn't tell rows this migration published apart from ones published independently afterward -- undoing it later means writing a new, deliberate migration instead.
This is a genuine publication decision, not a mechanical schema change -- confirm the 56 rows are actually appropriate for public release before merging.
---
.../20260714_0001_publish_project_areas.py | 44 +++++++++++++++++++
tests/test_data_migrations.py | 34 ++++++++++++++
2 files changed, 78 insertions(+)
create mode 100644 data_migrations/migrations/20260714_0001_publish_project_areas.py
diff --git a/data_migrations/migrations/20260714_0001_publish_project_areas.py b/data_migrations/migrations/20260714_0001_publish_project_areas.py
new file mode 100644
index 000000000..244231667
--- /dev/null
+++ b/data_migrations/migrations/20260714_0001_publish_project_areas.py
@@ -0,0 +1,44 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+from sqlalchemy import update
+from sqlalchemy.orm import Session
+
+from data_migrations.base import DataMigration
+from db.group import Group
+
+
+def run(session: Session) -> None:
+ session.execute(
+ update(Group)
+ .where(Group.project_area.isnot(None))
+ .values(release_status="public")
+ )
+ session.commit()
+
+
+MIGRATION = DataMigration(
+ id="20260714_0001_publish_project_areas",
+ alembic_revision="f4a5b6c7d8e9",
+ name="Publish all project_areas records",
+ description=(
+ "Marks every group record backing the project_areas OGC layer "
+ "(project_area IS NOT NULL) as release_status='public'. Confirmed "
+ "via docs/ogc-layer-audit.md that all 56 current rows are "
+ "release_status='draft'."
+ ),
+ run=run,
+ is_repeatable=False,
+)
diff --git a/tests/test_data_migrations.py b/tests/test_data_migrations.py
index 3b0ce5211..8c11177d0 100644
--- a/tests/test_data_migrations.py
+++ b/tests/test_data_migrations.py
@@ -20,8 +20,12 @@
move_notes = importlib.import_module(
"data_migrations.migrations.20260205_0001_move_nma_location_notes"
)
+publish_project_areas = importlib.import_module(
+ "data_migrations.migrations.20260714_0001_publish_project_areas"
+)
from db.location import Location
from db.notes import Notes
+from db.group import Group
from db.engine import session_ctx
@@ -105,3 +109,33 @@ def test_move_nma_location_notes_skips_duplicates():
session.delete(notes[0])
session.delete(location)
session.commit()
+
+
+def test_publish_project_areas_marks_project_area_groups_public():
+ with session_ctx() as session:
+ draft_with_area = Group(
+ name="Draft Project Area A",
+ description="Has a project area, should be published.",
+ release_status="draft",
+ project_area="MULTIPOLYGON(((-107.2 33.6, -106.6 33.6, -106.6 34.2, -107.2 34.2, -107.2 33.6)))",
+ )
+ draft_without_area = Group(
+ name="Draft No Area",
+ description="No project area, should be left alone.",
+ release_status="draft",
+ )
+ session.add_all([draft_with_area, draft_without_area])
+ session.commit()
+ session.refresh(draft_with_area)
+ session.refresh(draft_without_area)
+
+ publish_project_areas.run(session)
+
+ session.refresh(draft_with_area)
+ session.refresh(draft_without_area)
+ assert draft_with_area.release_status == "public"
+ assert draft_without_area.release_status == "draft"
+
+ session.delete(draft_with_area)
+ session.delete(draft_without_area)
+ session.commit()
From 716ca5284e6de57b360c230f4735a85fc0487ce9 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Wed, 15 Jul 2026 14:58:56 -0600
Subject: [PATCH 006/151] test(ogc): add behave coverage for public-only filter
Covers the migration-application, downgrade-reversibility, already-consistent-layers, and project_areas scenarios in ogc-cleanup-sprint1.feature tagged @A1. The other ~10 tickets sharing that feature file have no steps yet and stay undefined, per plan.
Tags @production only on these specific scenarios (the feature file has no feature-level tag since ~20 other tickets' scenarios share it) and @migration-mutates-schema on the three that call alembic downgrade, wiring a before/after_scenario hook in environment.py that unconditionally restores head so a downgrade left mid-scenario can't leak into later scenarios sharing this database.
---
tests/features/environment.py | 11 +-
tests/features/ogc-cleanup-sprint1.feature | 19 +-
tests/features/steps/ogc-cleanup-sprint1.py | 672 ++++++++++++++++++++
3 files changed, 695 insertions(+), 7 deletions(-)
create mode 100644 tests/features/steps/ogc-cleanup-sprint1.py
diff --git a/tests/features/environment.py b/tests/features/environment.py
index 9cdff0d62..72e3a65e2 100644
--- a/tests/features/environment.py
+++ b/tests/features/environment.py
@@ -741,10 +741,19 @@ def after_all(context):
def before_scenario(context, scenario):
# runs before EVERY scenario
# e.g. reset test data, open browser, etc.
- pass
+ if "migration-mutates-schema" in scenario.tags:
+ # Defense in depth against a previous, unrelated failure having
+ # already left the database below head.
+ command.upgrade(_alembic_config(), "head")
def after_scenario(context, scenario):
+ if "migration-mutates-schema" in scenario.tags:
+ # Runs whether the scenario passed or failed, so a downgrade left
+ # mid-scenario never leaks into later scenarios/features sharing
+ # this database. Deliberately not gated on DROP_AND_REBUILD_DB,
+ # since these scenarios mutate schema regardless of that flag.
+ command.upgrade(_alembic_config(), "head")
if not get_bool_env("DROP_AND_REBUILD_DB"):
return
diff --git a/tests/features/ogc-cleanup-sprint1.feature b/tests/features/ogc-cleanup-sprint1.feature
index 2049023bc..c510bb0fc 100644
--- a/tests/features/ogc-cleanup-sprint1.feature
+++ b/tests/features/ogc-cleanup-sprint1.feature
@@ -25,13 +25,13 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
# A1 — Apply release_status = 'public' filter to all OGC views
# ---------------------------------------------------------------------------
- @backend @ogc-exposure @sprint-1 @high-priority @A1
+ @backend @ogc-exposure @sprint-1 @high-priority @A1 @production @migration-mutates-schema @cleanup_samples
Scenario: Sprint 1 migration restricts all ogc_* views to public records
Given a clean database state before the Sprint 1 migration
When the Sprint 1 Alembic migration is applied
Then each ogc_* view returns only records with release_status "public"
- @backend @ogc-exposure @sprint-1 @high-priority @A1
+ @backend @ogc-exposure @sprint-1 @high-priority @A1 @production @migration-mutates-schema @cleanup_samples
Scenario: Sprint 1 migration can be reversed without error
Given the Sprint 1 migration has been applied
When the Sprint 1 migration downgrade is run
@@ -40,7 +40,7 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
And each ogc_* view returns the same count of draft records as before the migration
And no database errors are raised
- @backend @ogc-exposure @sprint-1 @high-priority @A1
+ @backend @ogc-exposure @sprint-1 @high-priority @A1 @production @cleanup_samples
Scenario: Non-public records are excluded from every exposure-affected OGC layer
Given the Sprint 1 migration has been applied
When a public client requests items from each of the following layers:
@@ -69,15 +69,22 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
# other_things above: A1 must apply the filter to its view, but A18 removes
# other_things from the catalog — run this scenario before A18 is applied
- @backend @ogc-exposure @sprint-1 @high-priority @A1
+ @backend @ogc-exposure @sprint-1 @high-priority @A1 @production
Scenario: project_areas returns 56 rows after all records are updated to public
Given all 56 project_areas records have been updated from release_status "draft" to release_status "public"
When a client requests features from the project_areas layer
Then the response contains 56 features
And the response HTTP status is 200
And all returned features have release_status "public"
-
- @backend @ogc-exposure @sprint-1 @high-priority @A1
+ # The "Given" precondition above is satisfied by a dedicated data
+ # migration (not the schema migration that adds the release_status
+ # filter) -- see the project_areas data migration in this ticket's
+ # implementation. Automated tests verify the underlying property
+ # (every project_area-bearing group ends up public), not the literal
+ # count 56, which is specific to today's real production data and is
+ # spot-checked manually against the target environment instead.
+
+ @backend @ogc-exposure @sprint-1 @high-priority @A1 @production @migration-mutates-schema @cleanup_samples
Scenario: The 4 already-consistent layers are unaffected by the migration
Given the following layers were already filtering correctly before the migration:
| layer-id |
diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py
new file mode 100644
index 000000000..b88da1130
--- /dev/null
+++ b/tests/features/steps/ogc-cleanup-sprint1.py
@@ -0,0 +1,672 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Step definitions for A1 (public release_status filter on ogc_* views).
+
+Only the @A1-tagged scenarios in ogc-cleanup-sprint1.feature are implemented
+here. The other ~10 tickets sharing that feature file have no steps yet and
+stay undefined/dormant, per this ticket's plan.
+"""
+import importlib
+from datetime import date
+
+from alembic import command
+from behave import given, when, then
+from sqlalchemy import text
+
+from core.dependencies import (
+ viewer_function,
+ amp_viewer_function,
+ amp_editor_function,
+ admin_function,
+ amp_admin_function,
+)
+from starlette.testclient import TestClient
+
+from db import (
+ Location,
+ Thing,
+ LocationThingAssociation,
+ Group,
+ GroupThingAssociation,
+ StatusHistory,
+ Contact,
+ FieldEvent,
+ FieldEventParticipant,
+ FieldActivity,
+ Sample,
+ Observation,
+ Sensor,
+ NMA_Chemistry_SampleInfo,
+ NMA_MajorChemistry,
+ NMA_MinorTraceChemistry,
+)
+from db.engine import session_ctx
+from tests import get_parameter_id
+from tests.features.environment import _alembic_config
+
+# Revision immediately before this ticket's schema migration -- re-verify
+# with `alembic heads`/`alembic history` if this file is revisited later,
+# since new migrations may have landed since.
+PRE_A1_REVISION = "y3z4a5b6c7d8"
+
+# Maps every OGC layer-id used in this feature file's data tables to the
+# seed group whose known public/private/draft ids should appear or not
+# appear in that layer. The 9 derived/summary layers and
+# actively_monitored_wells all key off the same seeded water wells.
+LAYER_ID_TO_SEED_KEY = {
+ "water_wells": "water_wells",
+ "springs": "springs",
+ "perennial_streams": "perennial_streams",
+ "meteorological_stations": "meteorological_stations",
+ "diversions_surface_water": "diversions_surface_water",
+ "lakes_ponds_reservoirs": "lakes_ponds_reservoirs",
+ "other_things": "other_things",
+ "water_well_summary": "water_wells",
+ "depth_to_water_trend_wells": "water_wells",
+ "water_elevation_wells": "water_wells",
+ "major_chemistry_results": "water_wells",
+ "minor_chemistry_wells": "water_wells",
+ "latest_tds_wells": "water_wells",
+ "actively_monitored_wells": "water_wells",
+ "avg_tds_wells": "water_wells",
+ "latest_depth_to_water_wells": "water_wells",
+ "locations": "locations",
+ "project_areas": "project_areas",
+ "ephemeral_streams": "ephemeral_streams",
+ "rock_sample_locations": "rock_sample_locations",
+ "soil_gas_sample_locations": "soil_gas_sample_locations",
+ "outfalls_wastewater_return_flow": "outfalls_wastewater_return_flow",
+}
+
+# Same mapping, keyed by the underlying ogc_* relation name, for the
+# SQL-level scenarios (migration-application, downgrade-reversibility).
+VIEW_TO_SEED_KEY = {
+ f"ogc_{layer_id}": seed_key for layer_id, seed_key in LAYER_ID_TO_SEED_KEY.items()
+}
+
+# ogc_locations does not exist before A1 (core/pygeoapi-config.yml pointed
+# directly at the raw location table), so downgrade drops it rather than
+# recreating an unfiltered copy -- there is no "count before the migration"
+# to compare it against once downgraded.
+NOT_PRESENT_BEFORE_A1 = {"ogc_locations"}
+
+# Thing-type layers backed by the shared _create_thing_view template --
+# a simple Location + Thing pair is enough to exercise these.
+SIMPLE_THING_TYPE_LAYERS = [
+ ("springs", "spring"),
+ ("perennial_streams", "perennial stream"),
+ ("meteorological_stations", "meteorological station"),
+ ("diversions_surface_water", "diversion of surface water, etc."),
+ ("lakes_ponds_reservoirs", "lake, pond or reservoir"),
+ ("other_things", "other"),
+ ("ephemeral_streams", "ephemeral stream"),
+ ("rock_sample_locations", "rock sample location"),
+ ("soil_gas_sample_locations", "soil gas sample location"),
+ ("outfalls_wastewater_return_flow", "outfall of wastewater or return flow"),
+]
+
+# The feature file's "4 already-consistent layers" scenario: today's live
+# data for these thing types happens to be 100% public already.
+ALREADY_CONSISTENT_LAYER_IDS = {
+ "ephemeral_streams",
+ "rock_sample_locations",
+ "soil_gas_sample_locations",
+ "outfalls_wastewater_return_flow",
+}
+
+STATUSES = ("public", "private", "draft")
+
+
+@given("the Ocotillo API is running")
+def step_given_ocotillo_api_is_running(context):
+ from main import app
+
+ def override_authentication(default=True):
+ def closure():
+ return default
+
+ return closure
+
+ app.dependency_overrides[amp_admin_function] = override_authentication(
+ default={"name": "foobar", "sub": "1234567890"}
+ )
+ app.dependency_overrides[admin_function] = override_authentication(
+ default={"name": "foobar", "sub": "1234567890"}
+ )
+ app.dependency_overrides[amp_editor_function] = override_authentication(
+ default={"name": "foobar", "sub": "1234567890"}
+ )
+ app.dependency_overrides[amp_viewer_function] = override_authentication()
+ app.dependency_overrides[viewer_function] = override_authentication()
+
+ context.client = TestClient(app)
+ assert context.client is not None, "TestClient failed to initialize"
+
+
+def _seed_thing_with_location(session, thing_type, release_status, name):
+ location = Location(
+ point="POINT(-106.5 34.0)",
+ elevation=1500.0,
+ release_status="public",
+ )
+ session.add(location)
+ session.commit()
+
+ thing = Thing(
+ name=name,
+ first_visit_date="2023-01-01",
+ thing_type=thing_type,
+ release_status=release_status,
+ )
+ session.add(thing)
+ session.commit()
+
+ assoc = LocationThingAssociation(location=location, thing=thing)
+ assoc.effective_start = "2023-01-01T00:00:00Z"
+ session.add(assoc)
+ session.commit()
+ session.refresh(thing)
+ return thing
+
+
+def _seed_water_well(session, release_status, name, monitoring_group):
+ well = _seed_thing_with_location(session, "water well", release_status, name)
+ # well.id is used to keep every unique-constrained field below distinct
+ # across repeated _seed_all() calls within the same behave run (this
+ # helper runs once per A1 scenario, sharing one database).
+ uid = well.id
+
+ # Observation chain feeding ogc_latest_depth_to_water_wells,
+ # ogc_depth_to_water_trend_wells, ogc_water_well_summary,
+ # ogc_water_elevation_wells.
+ contact = Contact(
+ name=f"A1 Contact {uid}",
+ role="Owner",
+ contact_type="Primary",
+ organization="NMBGMR",
+ release_status="draft",
+ )
+ session.add(contact)
+ session.commit()
+
+ field_event = FieldEvent(
+ thing_id=well.id,
+ event_date="2025-01-01T00:00:00Z",
+ notes="A1 behave seed field event",
+ release_status="draft",
+ )
+ session.add(field_event)
+ session.commit()
+
+ participant = FieldEventParticipant(
+ field_event_id=field_event.id,
+ contact_id=contact.id,
+ participant_role="Lead",
+ )
+ session.add(participant)
+ session.commit()
+
+ field_activity = FieldActivity(
+ field_event_id=field_event.id,
+ activity_type="groundwater level",
+ notes="A1 behave seed field activity",
+ release_status="draft",
+ )
+ session.add(field_activity)
+ session.commit()
+
+ sample = Sample(
+ field_activity_id=field_activity.id,
+ field_event_participant_id=participant.id,
+ sample_date="2025-01-01T12:00:00Z",
+ sample_name=f"A1 sample {uid}",
+ sample_matrix="water",
+ sample_method="Steel-tape measurement",
+ qc_type="Normal",
+ depth_top=None,
+ depth_bottom=None,
+ notes="A1 behave seed sample",
+ release_status="draft",
+ )
+ session.add(sample)
+ session.commit()
+
+ sensor = Sensor(
+ name=f"A1 Sensor {uid}",
+ sensor_type="Pressure Transducer",
+ model="Model X",
+ serial_no=f"A1-SN-{uid}",
+ pcn_number=f"A1-PCN-{uid}",
+ owner_agency="NMBGMR",
+ sensor_status="In Service",
+ notes="A1 behave seed sensor",
+ release_status="draft",
+ )
+ session.add(sensor)
+ session.commit()
+
+ observation = Observation(
+ observation_datetime="2025-01-01T00:04:00Z",
+ sample_id=sample.id,
+ sensor_id=sensor.id,
+ parameter_id=get_parameter_id("groundwater level", "Field Parameter"),
+ release_status="draft",
+ value=10.0,
+ unit="ft",
+ measuring_point_height=5.0,
+ groundwater_level_reason="Water level not affected",
+ )
+ session.add(observation)
+ session.commit()
+
+ # Chemistry rows feeding ogc_avg_tds_wells, ogc_latest_tds_wells,
+ # ogc_major_chemistry_results, ogc_minor_chemistry_wells.
+ # nma_sample_point_id is varchar(10) -- keep it short.
+ sample_point_id = f"A1{uid}"[:10]
+ csi = NMA_Chemistry_SampleInfo(
+ thing_id=well.id,
+ nma_sample_point_id=sample_point_id,
+ collection_date="2025-01-02T10:00:00Z",
+ )
+ session.add(csi)
+ session.flush()
+
+ major = NMA_MajorChemistry(
+ chemistry_sample_info_id=csi.id,
+ analyte="Total Dissolved Solids",
+ symbol="TDS",
+ sample_value=500.0,
+ units="mg/L",
+ analysis_date=None,
+ )
+ session.add(major)
+
+ minor = NMA_MinorTraceChemistry(
+ chemistry_sample_info_id=csi.id,
+ nma_sample_point_id=sample_point_id,
+ analyte="F",
+ symbol="",
+ sample_value=1.0,
+ units="mg/L",
+ analysis_date=date(2025, 1, 2),
+ )
+ session.add(minor)
+ session.commit()
+
+ # Water Level Network membership feeding ogc_actively_monitored_wells.
+ group_assoc = GroupThingAssociation(group_id=monitoring_group.id, thing_id=well.id)
+ session.add(group_assoc)
+ status_history = StatusHistory(
+ status_type="Monitoring Status",
+ status_value="Currently monitored",
+ start_date=date(2024, 1, 1),
+ end_date=None,
+ reason="A1 behave seed status",
+ target_id=well.id,
+ target_table="thing",
+ )
+ session.add(status_history)
+ session.commit()
+
+ return well
+
+
+def _seed_all(session):
+ """Seed one public/private/draft row per relevant thing type, one
+ draft Group with a project_area, and one standalone Location per
+ status. Returns {seed_key: {"public": id, "private": id, "draft": id}}.
+ """
+ seed_ids = {}
+
+ monitoring_group = Group(
+ name="Water Level Network",
+ description="A1 behave seed monitoring group",
+ release_status="public",
+ )
+ session.add(monitoring_group)
+ session.commit()
+
+ for layer_id, thing_type in SIMPLE_THING_TYPE_LAYERS:
+ seed_ids[layer_id] = {}
+ for status in STATUSES:
+ thing = _seed_thing_with_location(
+ session, thing_type, status, f"A1 {layer_id} {status}"
+ )
+ seed_ids[layer_id][status] = thing.id
+
+ seed_ids["water_wells"] = {}
+ for status in STATUSES:
+ well = _seed_water_well(
+ session, status, f"A1 water well {status}", monitoring_group
+ )
+ seed_ids["water_wells"][status] = well.id
+
+ seed_ids["project_areas"] = {}
+ for status in STATUSES:
+ group = Group(
+ name=f"A1 project area {status}",
+ description="A1 behave seed project area group",
+ release_status=status,
+ project_area=(
+ "MULTIPOLYGON(((-107.2 33.6, -106.6 33.6, "
+ "-106.6 34.2, -107.2 34.2, -107.2 33.6)))"
+ ),
+ )
+ session.add(group)
+ session.commit()
+ seed_ids["project_areas"][status] = group.id
+
+ seed_ids["locations"] = {}
+ for status in STATUSES:
+ location = Location(
+ point="POINT(-106.0 34.5)",
+ elevation=1600.0,
+ release_status=status,
+ )
+ session.add(location)
+ session.commit()
+ seed_ids["locations"][status] = location.id
+
+ # Materialized views are snapshots, not live queries -- the newly
+ # seeded rows are invisible to them until refreshed.
+ session.execute(text("SELECT public.refresh_materialized_views()"))
+ session.commit()
+
+ return seed_ids
+
+
+def _teardown_a1_seed_data():
+ """Delete every row these scenarios seed, by naming convention.
+
+ Without this, Thing/Group rows from an earlier A1 scenario in the same
+ behave run leak into a later scenario's absolute feature-count checks
+ (e.g. the already-consistent-layers scenario). Registered via
+ context.add_cleanup so it runs after the scenario regardless of
+ pass/fail, without needing an environment.py hook.
+ """
+ with session_ctx() as session:
+ session.execute(text("DELETE FROM thing WHERE name LIKE 'A1 %'"))
+ session.execute(
+ text(
+ "DELETE FROM \"group\" WHERE name LIKE 'A1 %' OR name = 'Water Level Network'"
+ )
+ )
+ session.commit()
+
+
+def _ensure_head(context):
+ command.upgrade(_alembic_config(), "head")
+ with session_ctx() as session:
+ context.seed_ids = _seed_all(session)
+ context.add_cleanup(_teardown_a1_seed_data)
+
+
+@given("a clean database state before the Sprint 1 migration")
+def step_given_clean_database_state(context):
+ command.downgrade(_alembic_config(), PRE_A1_REVISION)
+ with session_ctx() as session:
+ context.seed_ids = _seed_all(session)
+ context.add_cleanup(_teardown_a1_seed_data)
+
+
+@when("the Sprint 1 Alembic migration is applied")
+def step_when_sprint1_alembic_migration_is_applied(context):
+ command.upgrade(_alembic_config(), "head")
+
+
+@then('each ogc_* view returns only records with release_status "public"')
+def step_then_views_are_public_only(context):
+ with session_ctx() as session:
+ for relation, seed_key in VIEW_TO_SEED_KEY.items():
+ ids = context.seed_ids[seed_key]
+ for status in ("private", "draft"):
+ count = session.execute(
+ text(f"SELECT COUNT(*) FROM {relation} WHERE id = :id"),
+ {"id": ids[status]},
+ ).scalar()
+ assert count == 0, (
+ f"{relation} exposed a {status} row (id={ids[status]}) "
+ "that should have been filtered out"
+ )
+ public_count = session.execute(
+ text(f"SELECT COUNT(*) FROM {relation} WHERE id = :id"),
+ {"id": ids["public"]},
+ ).scalar()
+ assert public_count == 1, f"{relation} is missing its public seed row"
+
+
+@given("the Sprint 1 migration has been applied")
+def step_given_sprint1_migration_has_been_applied(context):
+ _ensure_head(context)
+
+
+@when("the Sprint 1 migration downgrade is run")
+def step_when_sprint1_migration_downgrade_is_run(context):
+ context.downgrade_error = None
+ try:
+ command.downgrade(_alembic_config(), PRE_A1_REVISION)
+ except Exception as exc: # noqa: BLE001 -- surfaced via the next Then step
+ context.downgrade_error = exc
+
+
+@then(
+ "each ogc_* view returns the same count of {status} records as before the migration"
+)
+def step_then_same_count_as_before_migration(context, status):
+ assert (
+ context.downgrade_error is None
+ ), f"Downgrade raised an error before counts could be checked: {context.downgrade_error}"
+ with session_ctx() as session:
+ for relation, seed_key in VIEW_TO_SEED_KEY.items():
+ if relation in NOT_PRESENT_BEFORE_A1:
+ continue
+ ids = context.seed_ids[seed_key]
+ count = session.execute(
+ text(f"SELECT COUNT(*) FROM {relation} WHERE id = :id"),
+ {"id": ids[status]},
+ ).scalar()
+ assert count == 1, (
+ f"{relation}: expected the seeded {status} row to be visible again "
+ f"after downgrade (pre-A1 had no filter), got count={count}"
+ )
+
+
+@then("no database errors are raised")
+def step_then_no_database_errors_are_raised(context):
+ assert (
+ context.downgrade_error is None
+ ), f"Downgrade raised: {context.downgrade_error}"
+ # Restore head immediately so later scenarios/features never run against
+ # a downgraded schema even if a later step in this scenario fails.
+ command.upgrade(_alembic_config(), "head")
+
+
+def _get_items(context, layer_id, limit=200):
+ response = context.client.get(f"/ogcapi/collections/{layer_id}/items?limit={limit}")
+ assert (
+ response.status_code == 200
+ ), f"Unexpected status {response.status_code} for layer {layer_id}: {response.text}"
+ return response.json()
+
+
+@when("a public client requests items from each of the following layers:")
+def step_when_public_client_requests_items_from_layers(context):
+ context.layer_responses = {}
+ for row in context.table:
+ layer_id = row["layer-id"].strip()
+ context.layer_responses[layer_id] = _get_items(context, layer_id)
+
+
+def _layer_feature_ids(payload):
+ ids = set()
+ for feature in payload["features"]:
+ feature_id = feature.get("id", feature.get("properties", {}).get("id"))
+ ids.add(feature_id)
+ return ids
+
+
+@then('each response contains only records where release_status is "public"')
+def step_then_each_response_contains_only_public(context):
+ for layer_id, payload in context.layer_responses.items():
+ seed_key = LAYER_ID_TO_SEED_KEY[layer_id]
+ features = payload["features"]
+ if features and "release_status" in features[0]["properties"]:
+ for feature in features:
+ assert (
+ feature["properties"]["release_status"] == "public"
+ ), f"{layer_id} returned a non-public record: {feature['properties']}"
+ else:
+ ids_present = _layer_feature_ids(payload)
+ public_id = context.seed_ids[seed_key]["public"]
+ assert (
+ public_id in ids_present
+ ), f"{layer_id} is missing its public seed row"
+
+
+@then('no response contains a record where release_status is "{status}"')
+def step_then_no_response_contains_status(context, status):
+ for layer_id, payload in context.layer_responses.items():
+ seed_key = LAYER_ID_TO_SEED_KEY[layer_id]
+ features = payload["features"]
+ if features and "release_status" in features[0]["properties"]:
+ for feature in features:
+ assert (
+ feature["properties"]["release_status"] != status
+ ), f"{layer_id} returned a {status} record: {feature['properties']}"
+ else:
+ ids_present = _layer_feature_ids(payload)
+ excluded_id = context.seed_ids[seed_key][status]
+ assert (
+ excluded_id not in ids_present
+ ), f"{layer_id} exposed its seeded {status} row (id={excluded_id})"
+
+
+@given(
+ 'all 56 project_areas records have been updated from release_status "draft" to release_status "public"'
+)
+def step_given_56_project_areas_updated_to_public(context):
+ publish_project_areas = importlib.import_module(
+ "data_migrations.migrations.20260714_0001_publish_project_areas"
+ )
+ command.upgrade(_alembic_config(), "head")
+ with session_ctx() as session:
+ groups = [
+ Group(
+ name=f"A1 56-count project area {i}",
+ description="A1 behave seed project area for the 56-row scenario",
+ release_status="draft",
+ project_area=(
+ "MULTIPOLYGON(((-107.0 33.0, -106.9 33.0, "
+ "-106.9 33.1, -107.0 33.1, -107.0 33.0)))"
+ ),
+ )
+ for i in range(56)
+ ]
+ session.add_all(groups)
+ session.commit()
+ context.project_area_group_ids = [g.id for g in groups]
+
+ publish_project_areas.run(session)
+
+
+@when("a client requests features from the project_areas layer")
+def step_when_client_requests_project_areas(context):
+ context.response = context.client.get(
+ "/ogcapi/collections/project_areas/items?limit=1000"
+ )
+
+
+@then("the response contains {count:d} features")
+def step_then_response_contains_n_features(context, count):
+ payload = context.response.json()
+ matching = [
+ f
+ for f in payload["features"]
+ if f.get("id", f.get("properties", {}).get("id"))
+ in set(context.project_area_group_ids)
+ ]
+ assert len(matching) == count, (
+ f"Expected {count} of this scenario's seeded project_areas features, "
+ f"found {len(matching)}"
+ )
+
+
+@then("the response HTTP status is {status:d}")
+def step_then_response_http_status_is(context, status):
+ assert (
+ context.response.status_code == status
+ ), f"Unexpected status {context.response.status_code}, expected {status}"
+
+
+@then('all returned features have release_status "public"')
+def step_then_all_returned_features_are_public(context):
+ payload = context.response.json()
+ for feature in payload["features"]:
+ if feature.get("id", feature.get("properties", {}).get("id")) not in set(
+ context.project_area_group_ids
+ ):
+ continue
+ assert (
+ feature["properties"]["release_status"] == "public"
+ ), f"Feature {feature.get('id')} was not public: {feature['properties']}"
+
+
+def _seed_already_consistent_layers(session):
+ """These 4 layers are asserted to be 100% public today -- unlike
+ _seed_all(), only public rows are seeded here. Seeding private/draft
+ rows into them would defeat the point of this scenario: proving the
+ filter changes nothing because there was never anything to filter.
+ """
+ for layer_id, thing_type in SIMPLE_THING_TYPE_LAYERS:
+ if layer_id not in ALREADY_CONSISTENT_LAYER_IDS:
+ continue
+ for i in range(3):
+ _seed_thing_with_location(
+ session, thing_type, "public", f"A1 already-consistent {layer_id} {i}"
+ )
+
+
+@given("the following layers were already filtering correctly before the migration:")
+def step_given_already_consistent_layers(context):
+ command.downgrade(_alembic_config(), PRE_A1_REVISION)
+ with session_ctx() as session:
+ _seed_already_consistent_layers(session)
+ session.commit()
+ context.add_cleanup(_teardown_a1_seed_data)
+
+ context.already_consistent_counts = {}
+ for row in context.table:
+ layer_id = row["layer-id"].strip()
+ payload = _get_items(context, layer_id, limit=500)
+ context.already_consistent_counts[layer_id] = len(payload["features"])
+
+
+@when("the Sprint 1 migration is applied")
+def step_when_sprint1_migration_is_applied(context):
+ command.upgrade(_alembic_config(), "head")
+
+
+@then("each of those layers returns the same feature count as before the migration")
+def step_then_same_feature_count_as_before(context):
+ for layer_id, before_count in context.already_consistent_counts.items():
+ payload = _get_items(context, layer_id, limit=500)
+ after_count = len(payload["features"])
+ assert (
+ after_count == before_count
+ ), f"{layer_id}: expected {before_count} features (unchanged), got {after_count}"
+
+
+# ============= EOF =============================================
From 5169404cae06f3274441bbdaf7a9467c261209ae Mon Sep 17 00:00:00 2001
From: ksmuczynski <20096455+ksmuczynski@users.noreply.github.com>
Date: Wed, 15 Jul 2026 22:55:14 +0000
Subject: [PATCH 007/151] Formatting changes
---
tests/features/steps/ogc-cleanup-sprint1.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py
index b88da1130..5c2f3e5ee 100644
--- a/tests/features/steps/ogc-cleanup-sprint1.py
+++ b/tests/features/steps/ogc-cleanup-sprint1.py
@@ -19,6 +19,7 @@
here. The other ~10 tickets sharing that feature file have no steps yet and
stay undefined/dormant, per this ticket's plan.
"""
+
import importlib
from datetime import date
From 5bf50c8af8727e77b5d5b7fc88fdecaeabaac5a2 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Fri, 17 Jul 2026 23:15:42 -0600
Subject: [PATCH 008/151] feat(ogc): add auth gate for internal OGC mount
pygeoapi is mounted via a raw Starlette Mount, so FastAPI's Depends() machinery never runs for it -- auth has to happen at the ASGI layer, in front of the mount, not as a route dependency.
Added TokenInvalid and decode_token_payload() alongside the existing _get_token_payload so the same JWKS/jwt.decode logic can be reused from code with no FastAPI exception handler watching (raising HTTPException there would just be an unhandled exception, not a response).
InternalOGCAuthMiddleware is a plain ASGI middleware class rather than BaseHTTPMiddleware (used elsewhere in this app for request logging and lazy admin init): BaseHTTPMiddleware buffers the full response body and breaks client-disconnect propagation, which matters here since this mount serves paginated GeoJSON up to max_items: 10000.
Splits 401 (no/invalid token) from 403 (valid token, wrong group) per the acceptance criteria, rather than collapsing both into 401 like every other auth path in this app does today -- the underlying check is identical either way, so the split is a one-line branch, not meaningfully more code to maintain.
---
core/dependencies.py | 6 ++
core/internal_ogc_auth.py | 115 ++++++++++++++++++++++++++++++++++++++
core/permissions.py | 32 +++++++++++
3 files changed, 153 insertions(+)
create mode 100644 core/internal_ogc_auth.py
diff --git a/core/dependencies.py b/core/dependencies.py
index eabcd009a..6372804a9 100644
--- a/core/dependencies.py
+++ b/core/dependencies.py
@@ -56,6 +56,12 @@
lexicon_editor_function = authenticated(permissions=["LexiconEditor"])
+# OGC-Internal Authentication/Permissions --------------------------------------
+# INTERNAL_OGC_GROUP ("OGCInternal") lives in core/permissions.py, not here --
+# it gates core/internal_ogc_auth.py's ASGI middleware in front of the
+# /ogcapi-internal mount, which runs outside FastAPI's Depends() machinery.
+
+
# Testing-Specific Authentication/Permissions ----------------------------------
no_permission_function = authenticated(permissions=["NoPermission"])
diff --git a/core/internal_ogc_auth.py b/core/internal_ogc_auth.py
new file mode 100644
index 000000000..8edd767cf
--- /dev/null
+++ b/core/internal_ogc_auth.py
@@ -0,0 +1,115 @@
+# ===============================================================================
+# Copyright 2026
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""ASGI auth gate for the authenticated internal OGC mount (/ogcapi-internal).
+
+pygeoapi is mounted via a raw Starlette Mount (core/pygeoapi.py), so FastAPI's
+Depends() machinery never runs for it -- gating has to happen at the ASGI
+layer, in front of the mount. This is a plain ASGI middleware class rather
+than @app.middleware("http")/BaseHTTPMiddleware (used elsewhere in this
+codebase): BaseHTTPMiddleware buffers the full response body and interferes
+with client-disconnect propagation, which matters here since
+/ogcapi-internal serves paginated GeoJSON up to `max_items: 10000`. On the
+success path this calls straight through with zero buffering.
+
+Kept separate from core/permissions.py to avoid a circular import with
+core/pygeoapi.py.
+"""
+
+import json
+import os
+
+from starlette.types import ASGIApp, Receive, Scope, Send
+
+from core import permissions
+from core.settings import settings
+
+
+def _extract_bearer_token(scope: Scope) -> str | None:
+ headers = dict(scope.get("headers") or [])
+ authorization = headers.get(b"authorization")
+ if not authorization:
+ return None
+ scheme, _, param = authorization.decode("latin-1").partition(" ")
+ if scheme.lower() != "bearer" or not param:
+ return None
+ return param
+
+
+async def _send_json(send: Send, status_code: int, detail: str) -> None:
+ body = json.dumps({"detail": detail}).encode("utf-8")
+ await send(
+ {
+ "type": "http.response.start",
+ "status": status_code,
+ "headers": [(b"content-type", b"application/json")],
+ }
+ )
+ await send({"type": "http.response.body", "body": body})
+
+
+class InternalOGCAuthMiddleware:
+ """Gates every request under `mount_path` behind INTERNAL_OGC_GROUP
+ membership; requests to any other path pass straight through untouched.
+
+ Registered via app.add_middleware(), which wraps the whole app -- the
+ path check below is what keeps this scoped to the internal mount only.
+ """
+
+ def __init__(self, app: ASGIApp, mount_path: str) -> None:
+ self.app = app
+ self.mount_path = mount_path
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ if scope["type"] != "http" or not scope["path"].startswith(self.mount_path):
+ await self.app(scope, receive, send)
+ return
+
+ if int(os.environ.get("AUTHENTIK_DISABLE_AUTHENTICATION", 0)):
+ if settings.mode == "production":
+ # HTTPException(424) (what core.permissions.authenticated()
+ # raises for this same misconfiguration) means nothing from
+ # raw ASGI code -- send the response directly so a
+ # misconfigured production box degrades to "internal mount
+ # always 424s" rather than crashing the worker.
+ await _send_json(
+ send,
+ 424,
+ "Authentication is disabled in production mode. Set "
+ "AUTHENTIK_DISABLE_AUTHENTICATION=0 to enable authentication.",
+ )
+ return
+ await self.app(scope, receive, send)
+ return
+
+ token = _extract_bearer_token(scope)
+ if not token:
+ await _send_json(send, 401, "Unauthorized")
+ return
+
+ try:
+ payload = permissions.decode_token_payload(token)
+ except permissions.TokenInvalid:
+ await _send_json(send, 401, "Could not validate credentials")
+ return
+
+ if permissions.INTERNAL_OGC_GROUP not in payload.get("groups", []):
+ await _send_json(send, 403, "Forbidden")
+ return
+
+ await self.app(scope, receive, send)
+
+
+# ============= EOF =============================================
diff --git a/core/permissions.py b/core/permissions.py
index 952e844f4..fec27d37f 100644
--- a/core/permissions.py
+++ b/core/permissions.py
@@ -153,4 +153,36 @@ def _get_token_payload(token: str = Depends(oauth2_scheme)):
)
+class TokenInvalid(Exception):
+ """Raised by decode_token_payload() for any JWT verification failure.
+
+ Not an HTTPException: this is called from raw ASGI middleware
+ (core/internal_ogc_auth.py), which has no FastAPI exception handler
+ watching, so raising HTTPException there would just be an unhandled
+ exception rather than the intended response.
+ """
+
+
+# Required Authentik group for the authenticated internal OGC mount
+# (/ogcapi-internal). Not Depends()-shaped like the roles above -- see the
+# cross-reference note in core/dependencies.py for why it still lives here.
+INTERNAL_OGC_GROUP = "OGCInternal"
+
+
+def decode_token_payload(token: str) -> dict:
+ """Same JWT verification as _get_token_payload (get_public_key/JWKS/
+ jwt.decode), but raises TokenInvalid instead of HTTPException(401).
+ """
+ try:
+ public_key = get_public_key(token)
+ return jwt.decode(
+ token,
+ public_key,
+ algorithms=ALGORITHMS,
+ audience=os.environ.get("AUTHENTIK_CLIENT_ID"),
+ )
+ except (JWTError, HTTPException) as e:
+ raise TokenInvalid(str(e)) from e
+
+
# ============= EOF =============================================
From d2e874943d9941f26fb74c60ac341e5da077fcc8 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Fri, 17 Jul 2026 23:18:06 -0600
Subject: [PATCH 009/151] feat(ogc): add authenticated internal OGC mount
Generalized _mount_path()/_pygeoapi_dir()/_write_config() to take an env var and default rather than hardcoding the public mount's values, so the internal mount gets the same traversal/character safety and sensitive-file handling for free instead of a copy-pasted second implementation.
mount_pygeoapi_internal() gets its own guard flag and runtime dir so it can't silently no-op against the public mount's state, and a startup check that the two configured mount paths actually differ -- Starlette doesn't error on duplicate Mounts, it just routes to whichever registered first, which would leave the internal mount silently unreachable rather than failing loudly.
Also added _assert_server_settings_match(): pygeoapi.api.API mutates process-wide globals (CHARSET, FORMAT_TYPES) during __init__, so whichever of the two mounts is built last wins for both. Inert today since both configs agree on encoding/gzip, but this fails startup loudly instead of letting a future divergence between the two configs silently corrupt responses on whichever mount lost the race.
---
.env.example | 7 +
core/factory.py | 3 +-
core/pygeoapi-config-internal.yml | 293 ++++++++++++++++++++++++++++++
core/pygeoapi.py | 145 ++++++++++++---
4 files changed, 425 insertions(+), 23 deletions(-)
create mode 100644 core/pygeoapi-config-internal.yml
diff --git a/.env.example b/.env.example
index 3b53b9ae7..518028d1c 100644
--- a/.env.example
+++ b/.env.example
@@ -10,6 +10,13 @@ POSTGRES_PORT=5432
PYGEOAPI_POSTGRES_PASSWORD=your_password
PYGEOAPI_POSTGRES_USER=your_username
+# PYGEOAPI internal mount (/ogcapi-internal) -- authenticated, unfiltered
+# (private/draft-inclusive) mirror of /ogcapi. Shares PYGEOAPI_POSTGRES_*
+# above; only the mount path, runtime dir, and advertised server URL differ.
+PYGEOAPI_INTERNAL_MOUNT_PATH=/ogcapi-internal
+PYGEOAPI_INTERNAL_RUNTIME_DIR=/tmp/pygeoapi-internal
+PYGEOAPI_INTERNAL_SERVER_URL=
+
# Connection pool configuration for parallel transfers
# pool_size: number of persistent connections to maintain
# max_overflow: additional connections allowed during peak usage
diff --git a/core/factory.py b/core/factory.py
index 69bcfba7e..029b0c2de 100644
--- a/core/factory.py
+++ b/core/factory.py
@@ -44,9 +44,10 @@ def create_api_app():
initialize_runtime()
app = create_base_app()
register_api_routes(app)
- from core.pygeoapi import mount_pygeoapi
+ from core.pygeoapi import mount_pygeoapi, mount_pygeoapi_internal
mount_pygeoapi(app)
+ mount_pygeoapi_internal(app)
if os.environ.get("SESSION_SECRET_KEY"):
configure_session_middleware(app)
configure_cors_middleware(app)
diff --git a/core/pygeoapi-config-internal.yml b/core/pygeoapi-config-internal.yml
new file mode 100644
index 000000000..7bfbb1590
--- /dev/null
+++ b/core/pygeoapi-config-internal.yml
@@ -0,0 +1,293 @@
+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 (Internal)
+ description: >-
+ Authenticated internal OGC API - Features backed by PostGIS and
+ pygeoapi. Unlike the public /ogcapi mount, these collections are not
+ filtered by release_status and include private and draft records.
+ keywords: [features, ogcapi, postgis, pygeoapi, internal]
+ 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: ogc_internal_locations
+ geom_field: point
+
+ latest_depth_to_water_wells:
+ type: collection
+ title: Latest Depth to Water (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_internal_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_internal_avg_tds_wells
+ geom_field: point
+
+ latest_tds_wells:
+ type: collection
+ title: Latest TDS (Water Wells)
+ description: Most recent total dissolved solids (TDS) result from major chemistry for each water well.
+ keywords: [water-wells, chemistry, tds, total-dissolved-solids, 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_internal_latest_tds_wells
+ geom_field: point
+
+ depth_to_water_trend_wells:
+ type: collection
+ title: Depth to Water Trend (Water Wells)
+ description: Trend classification for depth to water based on slope in feet per year.
+ keywords: [water-wells, groundwater-level, depth-to-water, trend, slope]
+ 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_internal_depth_to_water_trend_wells
+ geom_field: point
+
+ water_elevation_wells:
+ type: collection
+ title: Water Elevation (Water Wells)
+ description: Most recent water elevation per well calculated as elevation minus depth to water below ground surface.
+ keywords: [water-wells, groundwater-level, water-elevation, depth-to-water]
+ extents:
+ spatial:
+ bbox: [-109.05, 31.33, -103.00, 37.00]
+ crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
+ providers:
+ - type: feature
+ name: PostgreSQL
+ data:
+ host: {postgres_host}
+ port: {postgres_port}
+ dbname: {postgres_db}
+ user: {postgres_user}
+ password: {postgres_password_env}
+ search_path: [public]
+ id_field: id
+ table: ogc_internal_water_elevation_wells
+ geom_field: point
+
+ water_well_summary:
+ type: collection
+ title: Water Well Summary
+ description: Summary metrics per water well, including latest, min/max, and trend for water levels.
+ keywords: [water-wells, summary, groundwater-level, trend]
+ 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_internal_water_well_summary
+ geom_field: point
+
+ major_chemistry_results:
+ type: collection
+ title: Major Chemistry (Water Wells)
+ description: Latest major chemistry analyte values for water wells, represented as static analyte columns.
+ keywords: [water-wells, chemistry, analytes, major-chemistry]
+ extents:
+ spatial:
+ bbox: [-109.05, 31.33, -103.00, 37.00]
+ crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
+ providers:
+ - type: feature
+ name: PostgreSQL
+ data:
+ host: {postgres_host}
+ port: {postgres_port}
+ dbname: {postgres_db}
+ user: {postgres_user}
+ password: {postgres_password_env}
+ search_path: [public]
+ id_field: id
+ table: ogc_internal_major_chemistry_results
+ geom_field: point
+
+ minor_chemistry_wells:
+ type: collection
+ title: Minor Chemistry (Water Wells)
+ description: Latest minor/trace chemistry analyte values for water wells, represented as static analyte columns.
+ keywords: [water-wells, chemistry, analytes, minor-chemistry, trace-chemistry]
+ extents:
+ spatial:
+ bbox: [-109.05, 31.33, -103.00, 37.00]
+ crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
+ providers:
+ - type: feature
+ name: PostgreSQL
+ data:
+ host: {postgres_host}
+ port: {postgres_port}
+ dbname: {postgres_db}
+ user: {postgres_user}
+ password: {postgres_password_env}
+ search_path: [public]
+ id_field: id
+ table: ogc_internal_minor_chemistry_wells
+ geom_field: point
+
+ actively_monitored_wells:
+ type: collection
+ title: Actively Monitored Wells
+ description: Wells in the collaborative network currently flagged as actively monitored.
+ keywords: [water-wells, monitoring, collaborative-network, actively-monitored]
+ extents:
+ spatial:
+ bbox: [-109.05, 31.33, -103.00, 37.00]
+ crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
+ providers:
+ - type: feature
+ name: PostgreSQL
+ data:
+ host: {postgres_host}
+ port: {postgres_port}
+ dbname: {postgres_db}
+ user: {postgres_user}
+ password: {postgres_password_env}
+ search_path: [public]
+ id_field: id
+ table: ogc_internal_actively_monitored_wells
+ geom_field: point
+
+ project_areas:
+ type: collection
+ title: Project Areas
+ description: Project groups with polygon project-area boundaries.
+ keywords: [project-areas, groups, boundaries]
+ extents:
+ spatial:
+ bbox: [-109.05, 31.33, -103.00, 37.00]
+ crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
+ providers:
+ - type: feature
+ name: PostgreSQL
+ data:
+ host: {postgres_host}
+ port: {postgres_port}
+ dbname: {postgres_db}
+ user: {postgres_user}
+ password: {postgres_password_env}
+ search_path: [public]
+ id_field: id
+ table: ogc_internal_project_areas
+ geom_field: project_area
+
+{thing_collections_block}
diff --git a/core/pygeoapi.py b/core/pygeoapi.py
index 7783af100..bc422d024 100644
--- a/core/pygeoapi.py
+++ b/core/pygeoapi.py
@@ -108,13 +108,17 @@ 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()
+def _internal_template_path() -> Path:
+ return Path(__file__).resolve().parent / "pygeoapi-config-internal.yml"
+
+
+def _sanitized_mount_path(env_var: str, default: str) -> str:
+ # Read and sanitize the configured mount path, falling back to `default`.
+ path = (os.environ.get(env_var, default) or "").strip()
# Treat empty or root ("/") values as invalid and fall back to the default.
if path in {"", "/"}:
- path = "/ogcapi"
+ path = default
# Ensure a single leading slash.
if not path.startswith("/"):
@@ -127,21 +131,27 @@ def _mount_path() -> str:
# Disallow traversal/current-directory segments.
segments = [segment for segment in path.split("/") if segment]
if any(segment in {".", ".."} for segment in segments):
- raise ValueError(
- "Invalid PYGEOAPI_MOUNT_PATH: traversal segments are not allowed."
- )
+ raise ValueError(f"Invalid {env_var}: traversal segments are not allowed.")
# Allow only slash-delimited segments of alphanumerics, underscore,
# or hyphen.
if not re.fullmatch(r"/[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*", path):
raise ValueError(
- "Invalid PYGEOAPI_MOUNT_PATH: only letters, numbers, underscores, "
+ f"Invalid {env_var}: only letters, numbers, underscores, "
"hyphens, and slashes are allowed."
)
return path
+def _mount_path() -> str:
+ return _sanitized_mount_path("PYGEOAPI_MOUNT_PATH", "/ogcapi")
+
+
+def _internal_mount_path() -> str:
+ return _sanitized_mount_path("PYGEOAPI_INTERNAL_MOUNT_PATH", "/ogcapi-internal")
+
+
def _server_url() -> str:
configured = os.environ.get("PYGEOAPI_SERVER_URL")
if configured:
@@ -149,10 +159,19 @@ def _server_url() -> str:
return f"http://localhost:8000{_mount_path()}"
-def _pygeoapi_dir() -> Path:
+def _internal_server_url() -> str:
+ configured = os.environ.get("PYGEOAPI_INTERNAL_SERVER_URL")
+ if configured:
+ return configured.rstrip("/")
+ return f"http://localhost:8000{_internal_mount_path()}"
+
+
+def _pygeoapi_dir(
+ runtime_dir_env: str = "PYGEOAPI_RUNTIME_DIR", default: str = "/tmp/pygeoapi"
+) -> 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")
+ runtime_dir = (os.environ.get(runtime_dir_env) or "").strip()
+ path = Path(runtime_dir) if runtime_dir else Path(default)
path.mkdir(parents=True, exist_ok=True)
return path
@@ -163,6 +182,7 @@ def _thing_collections_block(
dbname: str,
user: str,
password_placeholder: str,
+ table_prefix: str = "ogc_",
) -> str:
resources: dict[str, dict] = {}
for collection in THING_COLLECTIONS:
@@ -190,7 +210,7 @@ def _thing_collections_block(
"search_path": ["public"],
},
"id_field": "id",
- "table": f"ogc_{collection['id']}",
+ "table": f"{table_prefix}{collection['id']}",
"geom_field": "point",
}
],
@@ -237,11 +257,17 @@ def _pygeoapi_db_settings() -> tuple[str, str, str, str, str]:
return host, port, dbname, user, "${PYGEOAPI_POSTGRES_PASSWORD}"
-def _write_config(path: Path) -> None:
+def _write_config(
+ path: Path,
+ *,
+ server_url: str,
+ table_prefix: str = "ogc_",
+ template_path: Path | None = None,
+) -> None:
host, port, dbname, user, password_placeholder = _pygeoapi_db_settings()
- template = _template_path().read_text(encoding="utf-8")
+ template = (template_path or _template_path()).read_text(encoding="utf-8")
config = template.format(
- server_url=_server_url(),
+ server_url=server_url,
postgres_host=host,
postgres_port=port,
postgres_db=dbname,
@@ -253,14 +279,15 @@ def _write_config(path: Path) -> None:
dbname=dbname,
user=user,
password_placeholder=password_placeholder,
+ table_prefix=table_prefix,
),
)
- # 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:
+ # NOTE: The generated runtime config file (default:
+ # `/tmp/pygeoapi/pygeoapi-config.yml` or
+ # `/tmp/pygeoapi-internal/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.
@@ -268,6 +295,32 @@ def _write_config(path: Path) -> None:
path.chmod(0o600)
+def _assert_server_settings_match(
+ public_config_path: Path, internal_config_path: Path
+) -> None:
+ # pygeoapi.api.API.__init__ mutates process-wide, module-level globals
+ # (CHARSET, FORMAT_TYPES) that persist across the importlib.reload this
+ # scheme relies on -- whichever mount is constructed last wins for both.
+ # Inert as long as both configs agree on these settings; fail loudly at
+ # startup rather than let a future divergence silently corrupt responses
+ # on whichever mount lost the race.
+ public_server = yaml.safe_load(public_config_path.read_text(encoding="utf-8")).get(
+ "server", {}
+ )
+ internal_server = yaml.safe_load(
+ internal_config_path.read_text(encoding="utf-8")
+ ).get("server", {})
+ for key in ("encoding", "gzip"):
+ if public_server.get(key) != internal_server.get(key):
+ raise RuntimeError(
+ "pygeoapi public/internal config drift detected: "
+ f"server.{key} differs ({public_server.get(key)!r} vs "
+ f"{internal_server.get(key)!r}). Both configs must agree "
+ "here since pygeoapi.api.API.__init__ mutates shared "
+ "process-wide globals from these settings."
+ )
+
+
def _generate_openapi(config_path: Path, openapi_path: Path) -> None:
from pygeoapi.openapi import generate_openapi_document
@@ -300,7 +353,7 @@ def mount_pygeoapi(app: FastAPI) -> None:
pygeoapi_dir = _pygeoapi_dir()
config_path = pygeoapi_dir / "pygeoapi-config.yml"
openapi_path = pygeoapi_dir / "pygeoapi-openapi.yml"
- _write_config(config_path)
+ _write_config(config_path, server_url=_server_url())
_generate_openapi(config_path, openapi_path)
os.environ["PYGEOAPI_CONFIG"] = str(config_path)
@@ -311,3 +364,51 @@ def mount_pygeoapi(app: FastAPI) -> None:
app.mount(mount_path, pygeoapi_app)
app.state.pygeoapi_mounted = True
+
+
+def mount_pygeoapi_internal(app: FastAPI) -> None:
+ if getattr(app.state, "pygeoapi_internal_mounted", False):
+ return
+ if find_spec("pygeoapi") is None:
+ raise RuntimeError(
+ "pygeoapi is not installed. Rebuild/sync dependencies so "
+ "/ogcapi-internal can be mounted."
+ )
+
+ public_mount_path = _mount_path()
+ internal_mount_path = _internal_mount_path()
+ if internal_mount_path == public_mount_path:
+ # Starlette doesn't error on duplicate mount paths -- it registers
+ # both Mounts and matches whichever was registered first (the
+ # public mount), leaving the internal mount silently unreachable.
+ # Fail loudly at startup instead of that way blind.
+ raise RuntimeError(
+ "PYGEOAPI_MOUNT_PATH and PYGEOAPI_INTERNAL_MOUNT_PATH both "
+ f"resolve to {internal_mount_path!r}. They must be distinct."
+ )
+
+ internal_dir = _pygeoapi_dir(
+ "PYGEOAPI_INTERNAL_RUNTIME_DIR", "/tmp/pygeoapi-internal"
+ )
+ config_path = internal_dir / "pygeoapi-config.yml"
+ openapi_path = internal_dir / "pygeoapi-openapi.yml"
+ _write_config(
+ config_path,
+ server_url=_internal_server_url(),
+ table_prefix="ogc_internal_",
+ template_path=_internal_template_path(),
+ )
+ _generate_openapi(config_path, openapi_path)
+ _assert_server_settings_match(_pygeoapi_dir() / "pygeoapi-config.yml", config_path)
+
+ os.environ["PYGEOAPI_CONFIG"] = str(config_path)
+ os.environ["PYGEOAPI_OPENAPI"] = str(openapi_path)
+
+ pygeoapi_app = _load_pygeoapi_app()
+
+ from core.internal_ogc_auth import InternalOGCAuthMiddleware
+
+ app.add_middleware(InternalOGCAuthMiddleware, mount_path=internal_mount_path)
+ app.mount(internal_mount_path, pygeoapi_app)
+
+ app.state.pygeoapi_internal_mounted = True
From 9e589ef993ff8005b65d4c4a20506970b02805e1 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Fri, 17 Jul 2026 23:20:33 -0600
Subject: [PATCH 010/151] feat(ogc): add unfiltered internal OGC views
Mirrors all 22 ogc_* relations f4a5b6c7d8e9 filters, as ogc_internal_* counterparts with no release_status predicate, so /ogcapi-internal can serve private/draft records to authenticated staff. Reuses f4a5b6c7d8e9's parametrized builder functions (public_only kept in the signature and always passed False) for structural parity rather than a differently-shaped rewrite, since that's what the drift-detection test in the next commit diffs against.
This repo keeps migrations self-contained with no cross-migration imports, so the two chemistry views' analyte-mapping CASE blocks are duplicated here rather than shared via a helper module -- a shared module would mean a future edit to one ticket's migration silently changes what the other replays from scratch. Added a one-line cross-reference comment in both files pointing at each other and the parity test.
ogc_internal_locations has no release_status predicate at all (unlike ogc_locations, which is always public-only even on its own downgrade path) since it never existed in any filtered form before this migration. downgrade() simply drops all 22 relations rather than restoring a prior state, since none of them existed before this migration.
---
.../2d3c3a268652_create_internal_ogc_views.py | 1304 +++++++++++++++++
...blic_release_status_filter_to_ogc_views.py | 6 +
2 files changed, 1310 insertions(+)
create mode 100644 alembic/versions/2d3c3a268652_create_internal_ogc_views.py
diff --git a/alembic/versions/2d3c3a268652_create_internal_ogc_views.py b/alembic/versions/2d3c3a268652_create_internal_ogc_views.py
new file mode 100644
index 000000000..affda020d
--- /dev/null
+++ b/alembic/versions/2d3c3a268652_create_internal_ogc_views.py
@@ -0,0 +1,1304 @@
+"""create internal ogc views
+
+Companion migration to f4a5b6c7d8e9 (public release_status filter on ogc_*
+views): creates a second, unfiltered copy of the same 22 relations, named
+ogc_internal_, backing the authenticated /ogcapi-internal mount
+(core/pygeoapi.py::mount_pygeoapi_internal). Full parity with the public
+set, per ticket A11 -- not a subset.
+
+The major/minor chemistry analyte-mapping CASE blocks and
+STATIC_ANALYTE_COLUMNS lists below are intentionally character-for-character
+identical (modulo view name) to their counterparts in f4a5b6c7d8e9 -- this
+codebase keeps migrations self-contained with no cross-migration imports, so
+the logic is duplicated here rather than shared. tests/test_migration_view_
+parity.py enforces the two stay in sync: if you fix an analyte mapping in
+one file, apply the same fix to the other.
+
+ogc_internal_locations has no release_status predicate at all (unlike
+ogc_locations, which is always public-only) -- the internal mount is
+unfiltered by design, and ogc_internal_locations never existed before this
+migration in any form.
+
+ogc_internal_actively_monitored_wells gets no predicate of its own -- like
+its public counterpart, it inherits whichever rows ogc_internal_water_well_
+summary exposes (here, all of them) transitively via a direct JOIN. Because
+of that JOIN, it must be dropped before ogc_internal_water_well_summary and
+recreated after (same ordering constraint as the public side).
+
+All 22 relations here are newly created by this migration -- none of them
+existed in any form beforehand -- so downgrade() simply drops them rather
+than recreating a prior state.
+
+Revision ID: 2d3c3a268652
+Revises: f4a5b6c7d8e9
+Create Date: 2026-07-16 00: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 = "2d3c3a268652"
+down_revision: Union[str, Sequence[str], None] = "f4a5b6c7d8e9"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+REQUIRED_TABLES = {
+ "thing",
+ "location",
+ "location_thing_association",
+ "group",
+ "group_thing_association",
+ "status_history",
+ "observation",
+ "sample",
+ "field_activity",
+ "field_event",
+ "data_provenance",
+ "NMA_MajorChemistry",
+ "NMA_Chemistry_SampleInfo",
+ "NMA_MinorTraceChemistry",
+}
+
+LATEST_LOCATION_CTE = """
+SELECT DISTINCT ON (lta.thing_id)
+ lta.thing_id,
+ lta.location_id,
+ lta.effective_start
+FROM location_thing_association AS lta
+WHERE lta.effective_end IS NULL
+ORDER BY lta.thing_id, lta.effective_start DESC
+""".strip()
+
+# Same 11 thing-type views as f4a5b6c7d8e9's THING_VIEWS.
+THING_VIEWS = [
+ ("water_wells", "water well"),
+ ("springs", "spring"),
+ ("diversions_surface_water", "diversion of surface water, etc."),
+ ("ephemeral_streams", "ephemeral stream"),
+ ("lakes_ponds_reservoirs", "lake, pond or reservoir"),
+ ("meteorological_stations", "meteorological station"),
+ ("other_things", "other"),
+ ("outfalls_wastewater_return_flow", "outfall of wastewater or return flow"),
+ ("perennial_streams", "perennial stream"),
+ ("rock_sample_locations", "rock sample location"),
+ ("soil_gas_sample_locations", "soil gas sample location"),
+]
+
+
+def _safe_view_id(view_id: str) -> str:
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", view_id):
+ raise ValueError(f"Unsafe view id: {view_id!r}")
+ return view_id
+
+
+def _drop_view_or_materialized_view(view_name: str) -> None:
+ # DROP VIEW IF EXISTS / DROP MATERIALIZED VIEW IF EXISTS only suppress
+ # "relation does not exist" -- Postgres still raises WrongObjectType if
+ # the relation exists as the other kind (e.g. DROP VIEW against an
+ # existing materialized view), so the relation's actual kind must be
+ # checked first rather than trying both blindly.
+ bind = op.get_bind()
+ relkind = bind.execute(
+ text("SELECT relkind FROM pg_class WHERE oid = to_regclass(:name)"),
+ {"name": view_name},
+ ).scalar()
+ if relkind == "m":
+ op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {view_name}"))
+ elif relkind == "v":
+ op.execute(text(f"DROP VIEW IF EXISTS {view_name}"))
+
+
+def _check_required_tables() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ existing_tables = set(inspector.get_table_names(schema="public"))
+ missing = REQUIRED_TABLES - existing_tables
+ if missing:
+ raise RuntimeError(
+ "Cannot create internal OGC views. "
+ f"Missing required tables: {', '.join(sorted(missing))}"
+ )
+
+
+def _create_thing_view(view_id: str, thing_type: str, public_only: bool) -> str:
+ safe_view_id = _safe_view_id(view_id)
+ escaped_thing_type = thing_type.replace("'", "''")
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE VIEW ogc_internal_{safe_view_id} AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ )
+ SELECT
+ t.id,
+ t.name,
+ t.first_visit_date,
+ t.nma_pk_welldata,
+ t.well_depth,
+ t.hole_depth,
+ t.well_casing_diameter,
+ t.well_casing_depth,
+ t.well_completion_date,
+ t.well_driller_name,
+ t.well_construction_method,
+ t.well_pump_type,
+ t.well_pump_depth,
+ t.formation_completion_code,
+ t.nma_formation_zone,
+ t.release_status,
+ l.elevation,
+ l.point
+ FROM thing AS t
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE t.thing_type = '{escaped_thing_type}'{release_filter}
+ """
+
+
+def _create_latest_depth_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_internal_latest_depth_to_water_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ ranked_obs AS (
+ SELECT
+ fe.thing_id,
+ o.id AS observation_id,
+ o.observation_datetime,
+ o.value,
+ 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{release_filter}
+ )
+ 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(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_internal_avg_tds_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ tds_obs AS (
+ SELECT
+ csi.thing_id,
+ mc.id AS major_chemistry_id,
+ COALESCE(mc."AnalysisDate", csi."CollectionDate")::date AS observation_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'
+ ){release_filter}
+ )
+ 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.observation_date) AS first_tds_observation_date,
+ MAX(to2.observation_date) AS last_tds_observation_date,
+ 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 _create_latest_tds_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE VIEW ogc_internal_latest_tds_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ tds_obs AS (
+ SELECT
+ csi.thing_id,
+ mc.id AS major_chemistry_id,
+ COALESCE(mc."AnalysisDate", csi."CollectionDate") AS observation_datetime,
+ 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'
+ ){release_filter}
+ ),
+ ranked_tds AS (
+ SELECT
+ to2.thing_id,
+ to2.major_chemistry_id,
+ to2.observation_datetime,
+ to2.sample_value,
+ to2.units,
+ ROW_NUMBER() OVER (
+ PARTITION BY to2.thing_id
+ ORDER BY to2.observation_datetime DESC NULLS LAST, to2.major_chemistry_id DESC
+ ) AS rn
+ FROM tds_obs AS to2
+ )
+ SELECT
+ t.id AS id,
+ t.name,
+ t.thing_type,
+ rt.major_chemistry_id,
+ rt.observation_datetime::date AS latest_tds_observation_date,
+ rt.sample_value AS latest_tds_value,
+ rt.units AS latest_tds_units,
+ l.point
+ FROM ranked_tds AS rt
+ JOIN thing AS t ON t.id = rt.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE rt.rn = 1
+ """
+
+
+def _create_depth_to_water_trend_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_internal_depth_to_water_trend_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ obs AS (
+ SELECT
+ fe.thing_id,
+ o.observation_datetime,
+ (o.value - COALESCE(o.measuring_point_height, 0)) AS depth_to_water_bgs
+ FROM observation AS o
+ JOIN sample AS s ON s.id = o.sample_id
+ JOIN field_activity AS fa ON fa.id = s.field_activity_id
+ JOIN field_event AS fe ON fe.id = fa.field_event_id
+ JOIN thing AS t ON t.id = fe.thing_id
+ WHERE
+ t.thing_type = 'water well'
+ AND fa.activity_type = 'groundwater level'
+ AND o.value IS NOT NULL
+ AND o.observation_datetime IS NOT NULL{release_filter}
+ ),
+ agg AS (
+ SELECT
+ ob.thing_id,
+ COUNT(*)::integer AS record_count,
+ MIN(ob.observation_datetime) AS first_observation_datetime,
+ MAX(ob.observation_datetime) AS last_observation_datetime,
+ EXTRACT(EPOCH FROM (MAX(ob.observation_datetime) - MIN(ob.observation_datetime)))
+ / 31557600.0 AS span_years,
+ REGR_SLOPE(
+ ob.depth_to_water_bgs,
+ EXTRACT(EPOCH FROM ob.observation_datetime)
+ ) * 31557600.0 AS slope_ft_per_year
+ FROM obs AS ob
+ GROUP BY ob.thing_id
+ )
+ SELECT
+ t.id AS id,
+ t.name,
+ t.thing_type,
+ a.record_count,
+ a.first_observation_datetime,
+ a.last_observation_datetime,
+ a.span_years,
+ a.slope_ft_per_year,
+ CASE
+ WHEN a.record_count >= 10 OR (a.record_count >= 4 AND a.span_years >= 2.0) THEN
+ CASE
+ WHEN a.slope_ft_per_year IS NULL THEN 'not enough data'
+ WHEN a.slope_ft_per_year > 0.25 THEN 'increasing'
+ WHEN a.slope_ft_per_year < -0.25 THEN 'decreasing'
+ ELSE 'stable'
+ END
+ ELSE 'not enough data'
+ END AS trend_category,
+ l.point
+ FROM agg AS a
+ JOIN thing AS t ON t.id = a.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ """
+
+
+def _create_water_well_summary_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_internal_water_well_summary AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ wl_obs AS (
+ SELECT
+ fe.thing_id,
+ o.id AS observation_id,
+ o.observation_datetime,
+ (o.value - COALESCE(o.measuring_point_height, 0)) AS water_level
+ FROM observation AS o
+ JOIN sample AS s ON s.id = o.sample_id
+ JOIN field_activity AS fa ON fa.id = s.field_activity_id
+ JOIN field_event AS fe ON fe.id = fa.field_event_id
+ JOIN thing AS t ON t.id = fe.thing_id
+ WHERE
+ t.thing_type = 'water well'
+ AND fa.activity_type = 'groundwater level'
+ AND o.value IS NOT NULL
+ AND o.observation_datetime IS NOT NULL{release_filter}
+ ),
+ wl_agg AS (
+ SELECT
+ w.thing_id,
+ COUNT(*)::integer AS total_water_levels,
+ MIN(w.water_level) AS min_water_level,
+ MAX(w.water_level) AS max_water_level,
+ REGR_SLOPE(
+ w.water_level,
+ EXTRACT(EPOCH FROM w.observation_datetime)
+ ) * 31557600.0 AS water_level_trend_ft_per_year
+ FROM wl_obs AS w
+ GROUP BY w.thing_id
+ ),
+ wl_last AS (
+ SELECT
+ ranked.thing_id,
+ ranked.water_level AS last_water_level,
+ ranked.observation_datetime AS last_water_level_datetime
+ FROM (
+ SELECT
+ w.thing_id,
+ w.water_level,
+ w.observation_datetime,
+ ROW_NUMBER() OVER (
+ PARTITION BY w.thing_id
+ ORDER BY w.observation_datetime DESC, w.observation_id DESC
+ ) AS rn
+ FROM wl_obs AS w
+ ) AS ranked
+ WHERE ranked.rn = 1
+ )
+ SELECT
+ t.id AS id,
+ t.name,
+ t.well_depth,
+ l.elevation,
+ dpl.collection_method AS elevation_method,
+ t.nma_formation_zone AS formation_zone,
+ wa.total_water_levels,
+ wl.last_water_level,
+ wl.last_water_level_datetime,
+ wa.min_water_level,
+ wa.max_water_level,
+ wa.water_level_trend_ft_per_year,
+ 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
+ JOIN wl_agg AS wa ON wa.thing_id = t.id
+ LEFT JOIN wl_last AS wl ON wl.thing_id = t.id
+ LEFT JOIN LATERAL (
+ SELECT dp.collection_method
+ FROM data_provenance AS dp
+ WHERE
+ dp.target_table = 'location'
+ AND dp.target_id = l.id
+ AND dp.field_name = 'elevation'
+ ORDER BY dp.id DESC
+ LIMIT 1
+ ) AS dpl ON true
+ WHERE t.thing_type = 'water well'
+ AND wa.total_water_levels > 0
+ """
+
+
+# Static analyte columns for major chemistry pivots.
+# Includes aliases observed in current DB values (e.g., Ca(total), IONBAL, TAn, TCat, Na+K).
+# Kept character-for-character identical to f4a5b6c7d8e9's copy -- see
+# tests/test_migration_view_parity.py.
+STATIC_ANALYTE_COLUMNS_MAJOR: list[tuple[str, str]] = [
+ ("tds", "tds"),
+ ("calcium", "calcium"),
+ ("calcium_total", "calcium_total"),
+ ("magnesium", "magnesium"),
+ ("magnesium_total", "magnesium_total"),
+ ("sodium", "sodium"),
+ ("sodium_total", "sodium_total"),
+ ("potassium", "potassium"),
+ ("potassium_total", "potassium_total"),
+ ("sodium_plus_potassium", "sodium_plus_potassium"),
+ ("bicarbonate", "bicarbonate"),
+ ("carbonate", "carbonate"),
+ ("sulfate", "sulfate"),
+ ("chloride", "chloride"),
+ ("ion_balance", "ion_balance"),
+ ("total_anions", "total_anions"),
+ ("total_cations", "total_cations"),
+ ("alkalinity", "alkalinity"),
+ ("hardness", "hardness"),
+ ("specific_conductance", "specific_conductance"),
+ ("ph", "ph"),
+ ("nitrate", "nitrate"),
+ ("fluoride", "fluoride"),
+ ("silica", "silica"),
+]
+
+
+def _major_chemistry_select_columns() -> str:
+ return ",\n".join(
+ [
+ (
+ " MAX(lr.sample_value) FILTER "
+ f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}"
+ )
+ for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MAJOR
+ ]
+ )
+
+
+def _major_chemistry_unit_columns() -> str:
+ return ",\n".join(
+ [
+ (
+ " MAX(lr.units) FILTER "
+ f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units"
+ )
+ for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MAJOR
+ ]
+ )
+
+
+def _create_major_chemistry_results_view(public_only: bool) -> str:
+ static_columns = _major_chemistry_select_columns()
+ static_unit_columns = _major_chemistry_unit_columns()
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_internal_major_chemistry_results AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ chemistry_rows AS (
+ SELECT
+ csi.thing_id,
+ mc.id AS result_id,
+ COALESCE(mc."AnalysisDate", csi."CollectionDate") AS observation_datetime,
+ trim(mc."Analyte") AS analyte_name,
+ trim(mc."Symbol") AS symbol_name,
+ mc."SampleValue"::double precision AS sample_value,
+ mc."Units" AS units
+ FROM "NMA_MajorChemistry" AS mc
+ JOIN "NMA_Chemistry_SampleInfo" AS csi
+ ON csi.id = mc.chemistry_sample_info_id
+ JOIN thing AS t
+ ON t.id = csi.thing_id
+ WHERE mc."SampleValue" IS NOT NULL
+ AND t.thing_type = 'water well'{release_filter}
+ ),
+ normalized_rows AS (
+ SELECT
+ cr.thing_id,
+ cr.result_id,
+ cr.observation_datetime,
+ NULLIF(
+ regexp_replace(
+ lower(trim(coalesce(cr.analyte_name, ''))),
+ '[^a-z0-9]+',
+ '',
+ 'g'
+ ),
+ ''
+ ) AS analyte_token,
+ NULLIF(
+ regexp_replace(
+ lower(trim(coalesce(cr.symbol_name, ''))),
+ '[^a-z0-9]+',
+ '',
+ 'g'
+ ),
+ ''
+ ) AS symbol_token,
+ cr.sample_value,
+ cr.units
+ FROM chemistry_rows AS cr
+ ),
+ mapped_rows AS (
+ SELECT
+ nr.thing_id,
+ nr.result_id,
+ nr.observation_datetime,
+ CASE
+ WHEN coalesce(nr.symbol_token, '') = 'tds'
+ OR coalesce(nr.analyte_token, '') IN ('tds', 'totaldissolvedsolids')
+ THEN 'tds'
+
+ WHEN coalesce(nr.symbol_token, '') = 'ca'
+ OR coalesce(nr.analyte_token, '') = 'ca'
+ THEN 'calcium'
+ WHEN coalesce(nr.analyte_token, '') = 'catotal'
+ THEN 'calcium_total'
+
+ WHEN coalesce(nr.symbol_token, '') = 'mg'
+ OR coalesce(nr.analyte_token, '') = 'mg'
+ THEN 'magnesium'
+ WHEN coalesce(nr.analyte_token, '') = 'mgtotal'
+ THEN 'magnesium_total'
+
+ WHEN coalesce(nr.symbol_token, '') = 'na'
+ OR coalesce(nr.analyte_token, '') = 'na'
+ THEN 'sodium'
+ WHEN coalesce(nr.analyte_token, '') = 'natotal'
+ THEN 'sodium_total'
+
+ WHEN coalesce(nr.symbol_token, '') = 'k'
+ OR coalesce(nr.analyte_token, '') = 'k'
+ THEN 'potassium'
+ WHEN coalesce(nr.analyte_token, '') = 'ktotal'
+ THEN 'potassium_total'
+
+ WHEN coalesce(nr.analyte_token, '') = 'nak'
+ THEN 'sodium_plus_potassium'
+
+ WHEN coalesce(nr.symbol_token, '') = 'hco3'
+ OR coalesce(nr.analyte_token, '') = 'hco3'
+ THEN 'bicarbonate'
+ WHEN coalesce(nr.symbol_token, '') = 'co3'
+ OR coalesce(nr.analyte_token, '') = 'co3'
+ THEN 'carbonate'
+ WHEN coalesce(nr.symbol_token, '') = 'so4'
+ OR coalesce(nr.analyte_token, '') = 'so4'
+ THEN 'sulfate'
+ WHEN coalesce(nr.symbol_token, '') = 'cl'
+ OR coalesce(nr.analyte_token, '') = 'cl'
+ THEN 'chloride'
+
+ WHEN coalesce(nr.analyte_token, '') = 'ionbal'
+ THEN 'ion_balance'
+ WHEN coalesce(nr.analyte_token, '') = 'tan'
+ THEN 'total_anions'
+ WHEN coalesce(nr.analyte_token, '') = 'tcat'
+ THEN 'total_cations'
+
+ WHEN coalesce(nr.analyte_token, '') IN ('alk', 'alkalinity')
+ THEN 'alkalinity'
+ WHEN coalesce(nr.analyte_token, '') IN ('hrd', 'hardness')
+ THEN 'hardness'
+ WHEN coalesce(nr.analyte_token, '') IN (
+ 'condlab',
+ 'specificconductance',
+ 'specificconductivity',
+ 'conductivity'
+ )
+ THEN 'specific_conductance'
+ WHEN coalesce(nr.symbol_token, '') = 'ph'
+ OR coalesce(nr.analyte_token, '') IN ('ph', 'phl')
+ THEN 'ph'
+
+ WHEN coalesce(nr.symbol_token, '') = 'no3'
+ OR coalesce(nr.analyte_token, '') IN ('no3', 'nitrate')
+ THEN 'nitrate'
+ WHEN coalesce(nr.symbol_token, '') = 'f'
+ OR coalesce(nr.analyte_token, '') IN ('f', 'fluoride')
+ THEN 'fluoride'
+ WHEN coalesce(nr.symbol_token, '') = 'sio2'
+ OR coalesce(nr.analyte_token, '') IN ('sio2', 'silica')
+ THEN 'silica'
+
+ ELSE NULL
+ END AS analyte_key,
+ nr.sample_value,
+ nr.units
+ FROM normalized_rows AS nr
+ ),
+ latest_results AS (
+ SELECT
+ mr.thing_id,
+ mr.analyte_key,
+ mr.sample_value,
+ mr.units,
+ mr.observation_datetime,
+ ROW_NUMBER() OVER (
+ PARTITION BY mr.thing_id, mr.analyte_key
+ ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC
+ ) AS rn
+ FROM mapped_rows AS mr
+ WHERE mr.analyte_key IS NOT NULL
+ )
+ SELECT
+ t.id AS id,
+ ll.location_id,
+ t.name,
+ t.thing_type,
+ COUNT(*)::integer AS analyte_count,
+ MAX(lr.observation_datetime::date) AS latest_chemistry_date,
+{static_columns},
+{static_unit_columns},
+ l.point
+ FROM latest_results AS lr
+ JOIN thing AS t ON t.id = lr.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE lr.rn = 1
+ GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point
+ """
+
+
+# Kept character-for-character identical to f4a5b6c7d8e9's copy -- see
+# tests/test_migration_view_parity.py.
+STATIC_ANALYTE_COLUMNS_MINOR: list[tuple[str, str]] = [
+ ("h2r", "h2r"),
+ ("o18r", "o18r"),
+ ("c13r", "c13r"),
+ ("c14", "c14"),
+ ("c14_years", "c14_years"),
+ ("fluoride", "fluoride"),
+ ("barium", "barium"),
+ ("barium_total", "barium_total"),
+ ("copper", "copper"),
+ ("copper_total", "copper_total"),
+ ("zinc", "zinc"),
+ ("zinc_total", "zinc_total"),
+ ("molybdenum", "molybdenum"),
+ ("molybdenum_total", "molybdenum_total"),
+ ("silica", "silica"),
+ ("silicon", "silicon"),
+ ("silicon_total", "silicon_total"),
+ ("manganese", "manganese"),
+ ("manganese_total", "manganese_total"),
+ ("iron", "iron"),
+ ("iron_total", "iron_total"),
+ ("strontium", "strontium"),
+ ("strontium_total", "strontium_total"),
+ ("chromium", "chromium"),
+ ("chromium_total", "chromium_total"),
+ ("boron", "boron"),
+ ("boron_total", "boron_total"),
+ ("uranium", "uranium"),
+ ("uranium_total", "uranium_total"),
+ ("lithium", "lithium"),
+ ("lithium_total", "lithium_total"),
+ ("silver", "silver"),
+ ("silver_total", "silver_total"),
+ ("antimony", "antimony"),
+ ("antimony_total", "antimony_total"),
+ ("beryllium", "beryllium"),
+ ("beryllium_total", "beryllium_total"),
+ ("lead", "lead"),
+ ("lead_total", "lead_total"),
+ ("thallium", "thallium"),
+ ("thallium_total", "thallium_total"),
+ ("bromide", "bromide"),
+ ("selenium", "selenium"),
+ ("selenium_total", "selenium_total"),
+ ("vanadium", "vanadium"),
+ ("vanadium_total", "vanadium_total"),
+ ("aluminum", "aluminum"),
+ ("aluminum_total", "aluminum_total"),
+ ("arsenic", "arsenic"),
+ ("arsenic_total", "arsenic_total"),
+ ("nickel", "nickel"),
+ ("nickel_total", "nickel_total"),
+ ("cadmium", "cadmium"),
+ ("cadmium_total", "cadmium_total"),
+ ("cobalt", "cobalt"),
+ ("cobalt_total", "cobalt_total"),
+ ("phosphate", "phosphate"),
+ ("nitrite", "nitrite"),
+ ("nitrate", "nitrate"),
+ ("nitrate_as_n", "nitrate_as_n"),
+ ("thorium", "thorium"),
+ ("thorium_total", "thorium_total"),
+ ("tin", "tin"),
+ ("tin_total", "tin_total"),
+ ("mercury", "mercury"),
+ ("mercury_total", "mercury_total"),
+ ("titanium", "titanium"),
+ ("titanium_total", "titanium_total"),
+]
+
+
+def _minor_chemistry_value_columns() -> str:
+ return ",\n".join(
+ [
+ (
+ " MAX(lr.sample_value) FILTER "
+ f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}"
+ )
+ for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MINOR
+ ]
+ )
+
+
+def _minor_chemistry_unit_columns() -> str:
+ return ",\n".join(
+ [
+ (
+ " MAX(lr.units) FILTER "
+ f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units"
+ )
+ for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MINOR
+ ]
+ )
+
+
+def _create_minor_chemistry_wells_view(public_only: bool) -> str:
+ value_columns = _minor_chemistry_value_columns()
+ unit_columns = _minor_chemistry_unit_columns()
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_internal_minor_chemistry_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ chemistry_rows AS (
+ SELECT
+ csi.thing_id,
+ mtc.id AS result_id,
+ COALESCE(mtc.analysis_date::timestamp, csi."CollectionDate") AS observation_datetime,
+ trim(mtc.analyte) AS analyte_name,
+ mtc.sample_value::double precision AS sample_value,
+ mtc.units AS units
+ FROM "NMA_MinorTraceChemistry" AS mtc
+ JOIN "NMA_Chemistry_SampleInfo" AS csi
+ ON csi.id = mtc.chemistry_sample_info_id
+ JOIN thing AS t ON t.id = csi.thing_id
+ WHERE
+ mtc.sample_value IS NOT NULL
+ AND t.thing_type = 'water well'{release_filter}
+ ),
+ normalized_rows AS (
+ SELECT
+ cr.thing_id,
+ cr.result_id,
+ cr.observation_datetime,
+ NULLIF(
+ regexp_replace(
+ lower(trim(coalesce(cr.analyte_name, ''))),
+ '[^a-z0-9]+',
+ '',
+ 'g'
+ ),
+ ''
+ ) AS analyte_token,
+ cr.sample_value,
+ cr.units
+ FROM chemistry_rows AS cr
+ ),
+ mapped_rows AS (
+ SELECT
+ nr.thing_id,
+ nr.result_id,
+ nr.observation_datetime,
+ CASE
+ WHEN coalesce(nr.analyte_token, '') = 'h2r' THEN 'h2r'
+ WHEN coalesce(nr.analyte_token, '') = 'o18r' THEN 'o18r'
+ WHEN coalesce(nr.analyte_token, '') = 'c13r' THEN 'c13r'
+ WHEN coalesce(nr.analyte_token, '') = 'c14' THEN 'c14'
+ WHEN coalesce(nr.analyte_token, '') = 'c14years' THEN 'c14_years'
+
+ WHEN coalesce(nr.analyte_token, '') = 'f' THEN 'fluoride'
+ WHEN coalesce(nr.analyte_token, '') = 'ba' THEN 'barium'
+ WHEN coalesce(nr.analyte_token, '') = 'batotal' THEN 'barium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'cu' THEN 'copper'
+ WHEN coalesce(nr.analyte_token, '') = 'cutotal' THEN 'copper_total'
+ WHEN coalesce(nr.analyte_token, '') = 'zn' THEN 'zinc'
+ WHEN coalesce(nr.analyte_token, '') = 'zntotal' THEN 'zinc_total'
+ WHEN coalesce(nr.analyte_token, '') = 'mo' THEN 'molybdenum'
+ WHEN coalesce(nr.analyte_token, '') = 'mototal' THEN 'molybdenum_total'
+ WHEN coalesce(nr.analyte_token, '') = 'sio2' THEN 'silica'
+ WHEN coalesce(nr.analyte_token, '') = 'si' THEN 'silicon'
+ WHEN coalesce(nr.analyte_token, '') = 'sitotal' THEN 'silicon_total'
+ WHEN coalesce(nr.analyte_token, '') = 'mn' THEN 'manganese'
+ WHEN coalesce(nr.analyte_token, '') = 'mntotal' THEN 'manganese_total'
+ WHEN coalesce(nr.analyte_token, '') = 'fe' THEN 'iron'
+ WHEN coalesce(nr.analyte_token, '') = 'fetotal' THEN 'iron_total'
+ WHEN coalesce(nr.analyte_token, '') = 'sr' THEN 'strontium'
+ WHEN coalesce(nr.analyte_token, '') = 'srtotal' THEN 'strontium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'cr' THEN 'chromium'
+ WHEN coalesce(nr.analyte_token, '') = 'crtotal' THEN 'chromium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'b' THEN 'boron'
+ WHEN coalesce(nr.analyte_token, '') = 'btotal' THEN 'boron_total'
+ WHEN coalesce(nr.analyte_token, '') = 'u' THEN 'uranium'
+ WHEN coalesce(nr.analyte_token, '') = 'utotal' THEN 'uranium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'li' THEN 'lithium'
+ WHEN coalesce(nr.analyte_token, '') = 'litotal' THEN 'lithium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'ag' THEN 'silver'
+ WHEN coalesce(nr.analyte_token, '') = 'agtotal' THEN 'silver_total'
+ WHEN coalesce(nr.analyte_token, '') = 'sb' THEN 'antimony'
+ WHEN coalesce(nr.analyte_token, '') = 'sbtotal' THEN 'antimony_total'
+ WHEN coalesce(nr.analyte_token, '') = 'be' THEN 'beryllium'
+ WHEN coalesce(nr.analyte_token, '') = 'betotal' THEN 'beryllium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'pb' THEN 'lead'
+ WHEN coalesce(nr.analyte_token, '') = 'pbtotal' THEN 'lead_total'
+ WHEN coalesce(nr.analyte_token, '') = 'tl' THEN 'thallium'
+ WHEN coalesce(nr.analyte_token, '') = 'tltotal' THEN 'thallium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'br' THEN 'bromide'
+ WHEN coalesce(nr.analyte_token, '') = 'se' THEN 'selenium'
+ WHEN coalesce(nr.analyte_token, '') = 'setotal' THEN 'selenium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'v' THEN 'vanadium'
+ WHEN coalesce(nr.analyte_token, '') = 'vtotal' THEN 'vanadium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'al' THEN 'aluminum'
+ WHEN coalesce(nr.analyte_token, '') = 'altotal' THEN 'aluminum_total'
+ WHEN coalesce(nr.analyte_token, '') = 'as' THEN 'arsenic'
+ WHEN coalesce(nr.analyte_token, '') = 'astotal' THEN 'arsenic_total'
+ WHEN coalesce(nr.analyte_token, '') = 'ni' THEN 'nickel'
+ WHEN coalesce(nr.analyte_token, '') = 'nitotal' THEN 'nickel_total'
+ WHEN coalesce(nr.analyte_token, '') = 'cd' THEN 'cadmium'
+ WHEN coalesce(nr.analyte_token, '') = 'cdtotal' THEN 'cadmium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'co' THEN 'cobalt'
+ WHEN coalesce(nr.analyte_token, '') = 'cototal' THEN 'cobalt_total'
+ WHEN coalesce(nr.analyte_token, '') = 'po4' THEN 'phosphate'
+ WHEN coalesce(nr.analyte_token, '') = 'no2' THEN 'nitrite'
+ WHEN coalesce(nr.analyte_token, '') = 'no3' THEN 'nitrate'
+ WHEN coalesce(nr.analyte_token, '') = 'no3n' THEN 'nitrate_as_n'
+ WHEN coalesce(nr.analyte_token, '') = 'th' THEN 'thorium'
+ WHEN coalesce(nr.analyte_token, '') = 'thtotal' THEN 'thorium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'sn' THEN 'tin'
+ WHEN coalesce(nr.analyte_token, '') = 'sntotal' THEN 'tin_total'
+ WHEN coalesce(nr.analyte_token, '') = 'hg' THEN 'mercury'
+ WHEN coalesce(nr.analyte_token, '') = 'hgtotal' THEN 'mercury_total'
+ WHEN coalesce(nr.analyte_token, '') = 'ti' THEN 'titanium'
+ WHEN coalesce(nr.analyte_token, '') = 'titotal' THEN 'titanium_total'
+ ELSE NULL
+ END AS analyte_key,
+ nr.sample_value,
+ nr.units
+ FROM normalized_rows AS nr
+ ),
+ latest_results AS (
+ SELECT
+ mr.thing_id,
+ mr.analyte_key,
+ mr.sample_value,
+ mr.units,
+ mr.observation_datetime,
+ ROW_NUMBER() OVER (
+ PARTITION BY mr.thing_id, mr.analyte_key
+ ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC
+ ) AS rn
+ FROM mapped_rows AS mr
+ WHERE mr.analyte_key IS NOT NULL
+ )
+ SELECT
+ t.id AS id,
+ ll.location_id,
+ t.name,
+ t.thing_type,
+ COUNT(*)::integer AS analyte_count,
+ MAX(lr.observation_datetime::date) AS latest_chemistry_date,
+{value_columns},
+{unit_columns},
+ l.point
+ FROM latest_results AS lr
+ JOIN thing AS t ON t.id = lr.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE lr.rn = 1
+ AND t.thing_type = 'water well'
+ GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point
+ """
+
+
+METERS_TO_FEET = 3.28084
+
+
+def _create_water_elevation_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_internal_water_elevation_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ ranked_obs AS (
+ SELECT
+ fe.thing_id,
+ o.id AS observation_id,
+ o.observation_datetime,
+ CASE
+ WHEN lower(trim(o.unit)) IN ('m', 'meter', 'meters', 'metre', 'metres') THEN
+ (o.value * {METERS_TO_FEET}) - COALESCE(o.measuring_point_height, 0)
+ WHEN lower(trim(o.unit)) IN ('ft', 'foot', 'feet') THEN
+ o.value - COALESCE(o.measuring_point_height, 0)
+ ELSE
+ NULL
+ END AS depth_to_water_below_ground_surface
+ FROM observation AS o
+ JOIN sample AS s ON s.id = o.sample_id
+ JOIN field_activity AS fa ON fa.id = s.field_activity_id
+ JOIN field_event AS fe ON fe.id = fa.field_event_id
+ JOIN thing AS t ON t.id = fe.thing_id
+ WHERE
+ t.thing_type = 'water well'
+ AND fa.activity_type = 'groundwater level'
+ AND o.value IS NOT NULL
+ AND o.observation_datetime IS NOT NULL
+ AND lower(trim(o.unit)) IN (
+ 'm',
+ 'meter',
+ 'meters',
+ 'metre',
+ 'metres',
+ 'ft',
+ 'foot',
+ 'feet'
+ ){release_filter}
+ ),
+ latest_obs AS (
+ SELECT
+ ro.*,
+ ROW_NUMBER() OVER (
+ PARTITION BY ro.thing_id
+ ORDER BY ro.observation_datetime DESC, ro.observation_id DESC
+ ) AS rn
+ FROM ranked_obs AS ro
+ )
+ SELECT
+ t.id AS id,
+ t.name,
+ t.thing_type,
+ lo.observation_id,
+ lo.observation_datetime,
+ l.elevation AS elevation_m,
+ lo.depth_to_water_below_ground_surface AS depth_to_water_below_ground_surface_ft,
+ ((l.elevation * {METERS_TO_FEET}) - lo.depth_to_water_below_ground_surface)
+ AS water_elevation_ft,
+ l.point
+ FROM latest_obs AS lo
+ JOIN thing AS t ON t.id = lo.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE lo.rn = 1
+ """
+
+
+def _create_actively_monitored_wells_view() -> str:
+ # No predicate of its own -- inherits whatever rows
+ # ogc_internal_water_well_summary exposes (here, all of them)
+ # transitively via the JOIN below. Mirrors the public side's
+ # ogc_actively_monitored_wells, which likewise never filters on
+ # status_history.release_status directly (see
+ # w1x2y3z4a5b6_drop_child_release_filters_from_ngwmn_views.py for why).
+ return """
+ CREATE VIEW ogc_internal_actively_monitored_wells AS
+ WITH latest_monitoring_status AS (
+ SELECT DISTINCT ON (sh.target_id)
+ sh.target_id AS thing_id,
+ sh.status_value
+ FROM status_history AS sh
+ WHERE
+ sh.target_table = 'thing'
+ AND sh.status_type = 'Monitoring Status'
+ ORDER BY sh.target_id, sh.start_date DESC, sh.id DESC
+ )
+ SELECT
+ wws.id,
+ wws.name,
+ 'water well'::text AS thing_type,
+ wws.well_depth,
+ wws.elevation,
+ wws.elevation_method,
+ wws.formation_zone,
+ wws.total_water_levels,
+ wws.last_water_level,
+ wws.last_water_level_datetime,
+ wws.min_water_level,
+ wws.max_water_level,
+ wws.water_level_trend_ft_per_year,
+ g.id AS group_id,
+ g.name AS group_name,
+ g.group_type,
+ wws.point
+ FROM "group" AS g
+ JOIN group_thing_association AS gta ON gta.group_id = g.id
+ JOIN ogc_internal_water_well_summary AS wws ON wws.id = gta.thing_id
+ JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id
+ WHERE lower(trim(g.name)) = 'water level network'
+ AND lms.status_value = 'Currently monitored'
+ """
+
+
+def _create_project_areas_view(public_only: bool) -> str:
+ release_filter = " AND g.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE VIEW ogc_internal_project_areas AS
+ SELECT
+ g.id,
+ g.name,
+ g.description,
+ g.group_type,
+ g.release_status,
+ g.project_area
+ FROM "group" AS g
+ WHERE g.project_area IS NOT NULL{release_filter}
+ """
+
+
+def _create_locations_view() -> str:
+ # Unlike ogc_locations (always public-only, even on the public side's
+ # downgrade path), ogc_internal_locations has no release_status
+ # predicate at all -- the internal mount is unfiltered by design, and
+ # this relation never existed in any form before this migration.
+ # Column list matches ogc_locations exactly; see
+ # f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py's
+ # _create_locations_view() for the db/location.py column verification.
+ return """
+ CREATE VIEW ogc_internal_locations AS
+ SELECT
+ l.id,
+ l.nma_pk_location,
+ l.description,
+ l.county,
+ l.state,
+ l.quad_name,
+ l.nma_location_notes,
+ l.nma_coordinate_notes,
+ l.nma_data_reliability,
+ l.nma_date_created,
+ l.nma_site_date,
+ l.release_status,
+ l.elevation,
+ l.point
+ FROM location AS l
+ """
+
+
+def _recreate_all_internal_views() -> None:
+ # ogc_internal_actively_monitored_wells depends on
+ # ogc_internal_water_well_summary via a direct JOIN; Postgres refuses to
+ # drop a materialized view while a dependent view exists, so it must go
+ # first and come back last -- same ordering constraint as the public side.
+ _drop_view_or_materialized_view("ogc_internal_actively_monitored_wells")
+
+ for view_id, thing_type in THING_VIEWS:
+ _drop_view_or_materialized_view(f"ogc_internal_{_safe_view_id(view_id)}")
+ op.execute(text(_create_thing_view(view_id, thing_type, public_only=False)))
+
+ _drop_view_or_materialized_view("ogc_internal_latest_depth_to_water_wells")
+ op.execute(text(_create_latest_depth_view(public_only=False)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_internal_latest_depth_to_water_wells IS "
+ "'Unfiltered latest depth-to-water per well view for the internal pygeoapi mount.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_internal_latest_depth_to_water_wells_id "
+ "ON ogc_internal_latest_depth_to_water_wells (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_internal_avg_tds_wells")
+ op.execute(text(_create_avg_tds_view(public_only=False)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_internal_avg_tds_wells IS "
+ "'Unfiltered average TDS per well from major chemistry results for the internal pygeoapi mount.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_internal_avg_tds_wells_id "
+ "ON ogc_internal_avg_tds_wells (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_internal_latest_tds_wells")
+ op.execute(text(_create_latest_tds_view(public_only=False)))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_internal_latest_tds_wells IS "
+ "'Unfiltered latest TDS per well from major chemistry results for the internal pygeoapi mount.'"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_internal_depth_to_water_trend_wells")
+ op.execute(text(_create_depth_to_water_trend_view(public_only=False)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_internal_depth_to_water_trend_wells IS "
+ "'Unfiltered depth-to-water trend classification for water wells, for the internal pygeoapi mount.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_internal_depth_to_water_trend_wells_id "
+ "ON ogc_internal_depth_to_water_trend_wells (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_internal_water_well_summary")
+ op.execute(text(_create_water_well_summary_view(public_only=False)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_internal_water_well_summary IS "
+ "'Unfiltered summary statistics for water wells including water-level trend, for the internal pygeoapi mount.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_internal_water_well_summary_id "
+ "ON ogc_internal_water_well_summary (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_internal_major_chemistry_results")
+ op.execute(text(_create_major_chemistry_results_view(public_only=False)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_internal_major_chemistry_results IS "
+ "'Unfiltered latest major-chemistry analyte values per location, pivoted into static analyte columns, for the internal pygeoapi mount.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_internal_major_chemistry_results_id "
+ "ON ogc_internal_major_chemistry_results (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_internal_minor_chemistry_wells")
+ op.execute(text(_create_minor_chemistry_wells_view(public_only=False)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_internal_minor_chemistry_wells IS "
+ "'Unfiltered latest minor/trace chemistry analyte values for water wells, pivoted into static analyte columns, for the internal pygeoapi mount.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_internal_minor_chemistry_wells_id "
+ "ON ogc_internal_minor_chemistry_wells (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_internal_water_elevation_wells")
+ op.execute(text(_create_water_elevation_view(public_only=False)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_internal_water_elevation_wells IS "
+ "'Unfiltered latest water elevation per well with explicit units: "
+ "elevation_m, depth_to_water_below_ground_surface_ft, water_elevation_ft, for the internal pygeoapi mount.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_internal_water_elevation_wells_id "
+ "ON ogc_internal_water_elevation_wells (id)"
+ )
+ )
+
+ # Recreate now that ogc_internal_water_well_summary exists again.
+ op.execute(text(_create_actively_monitored_wells_view()))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_internal_actively_monitored_wells IS "
+ "'Unfiltered wells in the Water Level Network group, for the internal pygeoapi mount.'"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_internal_project_areas")
+ op.execute(text(_create_project_areas_view(public_only=False)))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_internal_project_areas IS "
+ "'Unfiltered project areas for groups with polygon boundaries, for the internal pygeoapi mount.'"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_internal_locations")
+ op.execute(text(_create_locations_view()))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_internal_locations IS "
+ "'Unfiltered locations for the internal pygeoapi mount.'"
+ )
+ )
+
+
+# All 22 relations this migration creates, in an order safe for DROP (the
+# dependent view first, mirroring _recreate_all_internal_views's ordering).
+ALL_INTERNAL_RELATIONS = [
+ "ogc_internal_actively_monitored_wells",
+ *[f"ogc_internal_{view_id}" for view_id, _ in THING_VIEWS],
+ "ogc_internal_latest_depth_to_water_wells",
+ "ogc_internal_avg_tds_wells",
+ "ogc_internal_latest_tds_wells",
+ "ogc_internal_depth_to_water_trend_wells",
+ "ogc_internal_water_well_summary",
+ "ogc_internal_major_chemistry_results",
+ "ogc_internal_minor_chemistry_wells",
+ "ogc_internal_water_elevation_wells",
+ "ogc_internal_project_areas",
+ "ogc_internal_locations",
+]
+
+
+def upgrade() -> None:
+ _check_required_tables()
+ _recreate_all_internal_views()
+
+
+def downgrade() -> None:
+ # None of these 22 relations existed before this migration -- unlike
+ # f4a5b6c7d8e9's downgrade (which recreates the prior unfiltered public
+ # views), there is no prior state to restore, so downgrade just drops
+ # everything this migration created.
+ for relation in ALL_INTERNAL_RELATIONS:
+ _drop_view_or_materialized_view(relation)
diff --git a/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py b/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py
index 3cc644393..eb403749d 100644
--- a/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py
+++ b/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py
@@ -468,6 +468,9 @@ def _create_water_well_summary_view(public_only: bool) -> str:
# Static analyte columns for major chemistry pivots.
# Includes aliases observed in current DB values (e.g., Ca(total), IONBAL, TAn, TCat, Na+K).
+# Mirrored character-for-character (modulo view name) in
+# 2d3c3a268652_create_internal_ogc_views.py; tests/test_migration_view_parity.py
+# enforces the two stay in sync.
STATIC_ANALYTE_COLUMNS_MAJOR: list[tuple[str, str]] = [
("tds", "tds"),
("calcium", "calcium"),
@@ -694,6 +697,9 @@ def _create_major_chemistry_results_view(public_only: bool) -> str:
"""
+# Mirrored character-for-character (modulo view name) in
+# 2d3c3a268652_create_internal_ogc_views.py; tests/test_migration_view_parity.py
+# enforces the two stay in sync.
STATIC_ANALYTE_COLUMNS_MINOR: list[tuple[str, str]] = [
("h2r", "h2r"),
("o18r", "o18r"),
From 8eae5b17c70aa0ed432f5e4cf41f94b25bcf7ffd Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Fri, 17 Jul 2026 23:21:51 -0600
Subject: [PATCH 011/151] test(ogc): guard against analyte-mapping drift
The major/minor chemistry CASE blocks now exist in two files (f4a5b6c7d8e9 and 2d3c3a268652) because migrations can't import from each other. Without a check, a future analyte-mapping fix applied to the public view (e.g. reported against a public-facing bug) has no reason to also touch the internal view sitting in a different file from a different ticket -- silently leaving internal staff looking at the stale mapping, which inverts who has the more reliable data.
Compares AST source segments rather than raw text/regex so the comparison survives incidental formatting differences and only fails on a genuine logic change. Normalizes the one expected difference (the view name embedded in the CREATE statement) before comparing.
---
tests/test_migration_view_parity.py | 76 +++++++++++++++++++++++++++++
1 file changed, 76 insertions(+)
create mode 100644 tests/test_migration_view_parity.py
diff --git a/tests/test_migration_view_parity.py b/tests/test_migration_view_parity.py
new file mode 100644
index 000000000..5c6035b80
--- /dev/null
+++ b/tests/test_migration_view_parity.py
@@ -0,0 +1,76 @@
+"""Drift detection between the public (A1) and internal (A11) OGC migrations.
+
+The two chemistry pivot views (major/minor) are dominated by long
+analyte-alias CASE-mapping blocks that encode real lab-data business
+knowledge. Because this codebase keeps Alembic migrations self-contained
+with no cross-migration imports, that logic is duplicated rather than
+shared between f4a5b6c7d8e9 (public) and 2d3c3a268652 (internal). If someone
+fixes an analyte mapping in one file without the other, the public and
+internal chemistry layers silently diverge -- this test turns that into a
+loud, specific CI failure instead.
+"""
+
+import ast
+from pathlib import Path
+
+import pytest
+
+VERSIONS_DIR = Path(__file__).resolve().parent.parent / "alembic" / "versions"
+PUBLIC_MIGRATION = (
+ VERSIONS_DIR / "f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py"
+)
+INTERNAL_MIGRATION = VERSIONS_DIR / "2d3c3a268652_create_internal_ogc_views.py"
+
+# Substrings that are expected to differ between the two files -- normalized
+# away before comparison. Order matters: the internal_ variant must be
+# stripped before its shorter public counterpart could ever match it.
+NAME_SUBSTITUTIONS = [
+ ("ogc_internal_major_chemistry_results", "ogc_major_chemistry_results"),
+ ("ogc_internal_minor_chemistry_wells", "ogc_minor_chemistry_wells"),
+]
+
+COMPARED_NAMES = [
+ "STATIC_ANALYTE_COLUMNS_MAJOR",
+ "STATIC_ANALYTE_COLUMNS_MINOR",
+ "_major_chemistry_select_columns",
+ "_major_chemistry_unit_columns",
+ "_minor_chemistry_value_columns",
+ "_minor_chemistry_unit_columns",
+ "_create_major_chemistry_results_view",
+ "_create_minor_chemistry_wells_view",
+]
+
+
+def _get_node_source(path: Path, name: str) -> str:
+ source = path.read_text(encoding="utf-8")
+ tree = ast.parse(source, filename=str(path))
+ for node in ast.walk(tree):
+ if isinstance(node, ast.FunctionDef) and node.name == name:
+ return ast.get_source_segment(source, node)
+ if isinstance(node, ast.Assign):
+ targets = [t.id for t in node.targets if isinstance(t, ast.Name)]
+ if name in targets:
+ return ast.get_source_segment(source, node)
+ # STATIC_ANALYTE_COLUMNS_MAJOR/MINOR are annotated assignments
+ # (`NAME: list[...] = [...]`), which parse as AnnAssign, not Assign.
+ if isinstance(node, ast.AnnAssign):
+ if isinstance(node.target, ast.Name) and node.target.id == name:
+ return ast.get_source_segment(source, node)
+ raise AssertionError(f"{name!r} not found in {path}")
+
+
+def _normalize(source: str) -> str:
+ for internal, public in NAME_SUBSTITUTIONS:
+ source = source.replace(internal, public)
+ return source
+
+
+@pytest.mark.parametrize("name", COMPARED_NAMES)
+def test_analyte_mapping_matches_between_public_and_internal_migrations(name):
+ public_source = _normalize(_get_node_source(PUBLIC_MIGRATION, name))
+ internal_source = _normalize(_get_node_source(INTERNAL_MIGRATION, name))
+ assert public_source == internal_source, (
+ f"{name} has drifted between the public (f4a5b6c7d8e9) and internal "
+ "(2d3c3a268652) OGC migrations -- if this is a genuine analyte-mapping "
+ "fix, apply it to both files."
+ )
From f59a12ad0239a7687e0a8cd32db9d00ca9524682 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Fri, 17 Jul 2026 23:22:49 -0600
Subject: [PATCH 012/151] test(ogc): add behave coverage for internal OGC mount
Tags the 6 A11 scenarios A1 already wrote into this shared feature file with @production, appended to their existing tag lines rather than at the feature level -- this file has no feature-level tag since it's shared across ~10 other tickets' scenarios, and tagging at that level would pull in every other ticket's undefined steps.
The 401/403/200 auth scenarios neutralize the ambient AUTHENTIK_DISABLE_AUTHENTICATION dev-bypass for their own duration only (restored via context.add_cleanup) and patch core.permissions.decode_token_payload directly, since no real Authentik server is available in CI to issue a genuine JWT. Existing auth-testing infrastructure in this repo only reaches app.dependency_overrides, which has zero effect on ASGI middleware -- none of it exercises InternalOGCAuthMiddleware for free.
---
tests/features/ogc-cleanup-sprint1.feature | 12 +-
tests/features/steps/ogc-cleanup-sprint1.py | 191 +++++++++++++++++++-
2 files changed, 193 insertions(+), 10 deletions(-)
diff --git a/tests/features/ogc-cleanup-sprint1.feature b/tests/features/ogc-cleanup-sprint1.feature
index c510bb0fc..c8c325dad 100644
--- a/tests/features/ogc-cleanup-sprint1.feature
+++ b/tests/features/ogc-cleanup-sprint1.feature
@@ -178,38 +178,38 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
# A11 — Stand up authenticated internal OGC mount at /ogcapi-internal
# ---------------------------------------------------------------------------
- @backend @ogc-infrastructure @sprint-1 @high-priority @A11
+ @backend @ogc-infrastructure @sprint-1 @high-priority @A11 @production
Scenario: Anonymous request to internal OGC endpoint is rejected
When an unauthenticated client requests /ogcapi-internal/collections
Then the response HTTP status is 401
- @backend @ogc-infrastructure @sprint-1 @high-priority @A11
+ @backend @ogc-infrastructure @sprint-1 @high-priority @A11 @production
Scenario: Request with insufficient role to internal OGC endpoint is rejected
Given the client presents a valid token with role "public-viewer"
When the client requests /ogcapi-internal/collections
Then the response HTTP status is 403
- @backend @ogc-infrastructure @sprint-1 @high-priority @A11
+ @backend @ogc-infrastructure @sprint-1 @high-priority @A11 @production
Scenario: Authenticated internal staff can access /ogcapi-internal collections
Given an internal staff member with the required role is authenticated via Authentik
When the staff member requests /ogcapi-internal/collections
Then the response HTTP status is 200
And the response includes collections not available on the public /ogcapi endpoint
- @backend @ogc-infrastructure @sprint-1 @high-priority @A11
+ @backend @ogc-infrastructure @sprint-1 @high-priority @A11 @production
Scenario: Internal collections expose private and draft records
Given an authenticated internal staff member
When the staff member requests items from the "water_wells" internal collection
Then records with a release_status other than "public" are included in the response
- @backend @ogc-infrastructure @sprint-1 @high-priority @A11
+ @backend @ogc-infrastructure @sprint-1 @high-priority @A11 @production
Scenario: Internal database relations are separate from public relations
Given the /ogcapi-internal mount has been deployed
When the database schema is inspected
Then the database schema contains relations prefixed with "ogc_internal_"
And no ogc_internal_ relation is shared with the public /ogcapi endpoint
- @backend @ogc-infrastructure @sprint-1 @high-priority @A11
+ @backend @ogc-infrastructure @sprint-1 @high-priority @A11 @production
Scenario: Public /ogcapi surface is unaffected by the internal mount
When a client requests /ogcapi/collections
Then no collection in the response has an id prefixed "ogc_internal_"
diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py
index 5c2f3e5ee..51e744d8a 100644
--- a/tests/features/steps/ogc-cleanup-sprint1.py
+++ b/tests/features/steps/ogc-cleanup-sprint1.py
@@ -13,15 +13,18 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
-"""Step definitions for A1 (public release_status filter on ogc_* views).
+"""Step definitions for A1 (public release_status filter on ogc_* views) and
+A11 (authenticated internal OGC mount at /ogcapi-internal).
-Only the @A1-tagged scenarios in ogc-cleanup-sprint1.feature are implemented
-here. The other ~10 tickets sharing that feature file have no steps yet and
-stay undefined/dormant, per this ticket's plan.
+Only the @A1- and @A11-tagged scenarios in ogc-cleanup-sprint1.feature are
+implemented here. The other ~9 tickets sharing that feature file have no
+steps yet and stay undefined/dormant, per this ticket's plan.
"""
import importlib
+import os
from datetime import date
+from unittest.mock import patch
from alembic import command
from behave import given, when, then
@@ -34,6 +37,7 @@
admin_function,
amp_admin_function,
)
+from core.permissions import INTERNAL_OGC_GROUP
from starlette.testclient import TestClient
from db import (
@@ -670,4 +674,183 @@ def step_then_same_feature_count_as_before(context):
), f"{layer_id}: expected {before_count} features (unchanged), got {after_count}"
+# ---------------------------------------------------------------------------
+# A11 -- authenticated internal OGC mount (/ogcapi-internal)
+# ---------------------------------------------------------------------------
+#
+# tests/test_pygeoapi_mount.py's existing coverage and the "a functioning
+# api" step above both only touch FastAPI's app.dependency_overrides, which
+# has zero effect on ASGI middleware -- none of this codebase's existing
+# auth-testing infrastructure reaches InternalOGCAuthMiddleware for free.
+# The 401/403/200 scenarios below instead neutralize the ambient
+# AUTHENTIK_DISABLE_AUTHENTICATION dev-bypass (set for the whole bdd-tests CI
+# job) for the scenario's duration only, and control
+# core.permissions.decode_token_payload's return value directly, since no
+# real Authentik server is available in CI to issue a genuine JWT.
+
+
+def _neutralize_authentik_bypass(context):
+ """Temporarily force the dev-bypass off so InternalOGCAuthMiddleware's
+ real 401/403 logic actually runs, restored unconditionally afterward so
+ later scenarios relying on the global CI dev-bypass are unaffected.
+ """
+ original = os.environ.get("AUTHENTIK_DISABLE_AUTHENTICATION")
+ os.environ["AUTHENTIK_DISABLE_AUTHENTICATION"] = "0"
+
+ def _restore():
+ if original is None:
+ os.environ.pop("AUTHENTIK_DISABLE_AUTHENTICATION", None)
+ else:
+ os.environ["AUTHENTIK_DISABLE_AUTHENTICATION"] = original
+
+ context.add_cleanup(_restore)
+
+
+def _patch_decode_token_payload(context, groups):
+ patcher = patch(
+ "core.permissions.decode_token_payload", return_value={"groups": groups}
+ )
+ patcher.start()
+ context.add_cleanup(patcher.stop)
+
+
+def _teardown_a11_seed_data():
+ with session_ctx() as session:
+ session.execute(text("DELETE FROM thing WHERE name LIKE 'A11 %'"))
+ session.commit()
+
+
+@when("an unauthenticated client requests /ogcapi-internal/collections")
+def step_when_unauthenticated_client_requests_internal_collections(context):
+ _neutralize_authentik_bypass(context)
+ context.response = context.client.get("/ogcapi-internal/collections")
+
+
+@given('the client presents a valid token with role "{role}"')
+def step_given_client_presents_token_with_role(context, role):
+ _neutralize_authentik_bypass(context)
+ _patch_decode_token_payload(context, groups=[role])
+ context.auth_token = "a11-behave-test-token"
+
+
+@when("the client requests /ogcapi-internal/collections")
+def step_when_client_requests_internal_collections(context):
+ headers = {"Authorization": f"Bearer {context.auth_token}"}
+ context.response = context.client.get(
+ "/ogcapi-internal/collections", headers=headers
+ )
+
+
+@given("an internal staff member with the required role is authenticated via Authentik")
+def step_given_internal_staff_authenticated_via_authentik(context):
+ _neutralize_authentik_bypass(context)
+ _patch_decode_token_payload(context, groups=[INTERNAL_OGC_GROUP])
+ context.auth_token = "a11-behave-test-token"
+
+
+@when("the staff member requests /ogcapi-internal/collections")
+def step_when_staff_member_requests_internal_collections(context):
+ headers = {"Authorization": f"Bearer {context.auth_token}"}
+ context.response = context.client.get(
+ "/ogcapi-internal/collections", headers=headers
+ )
+
+
+@then("the response includes collections not available on the public /ogcapi endpoint")
+def step_then_response_includes_internal_only_collections(context):
+ payload = context.response.json()
+ collections = payload.get("collections", [])
+ assert collections, "internal endpoint returned no collections"
+ for collection in collections:
+ links = collection.get("links", [])
+ assert any("/ogcapi-internal" in link.get("href", "") for link in links), (
+ f"{collection.get('id')}: no self-link referencing /ogcapi-internal -- "
+ "expected each internal collection representation to be reachable "
+ "only via the internal mount, not the public /ogcapi endpoint"
+ )
+
+
+@given("an authenticated internal staff member")
+def step_given_an_authenticated_internal_staff_member(context):
+ # Relies on the ambient AUTHENTIK_DISABLE_AUTHENTICATION dev-bypass (set
+ # for the whole CI job) rather than a real token -- this scenario is
+ # about what the internal mount exposes, not auth semantics (covered
+ # separately by the 401/403/200 scenarios above).
+ with session_ctx() as session:
+ context.a11_seed_ids = {}
+ for status in STATUSES:
+ thing = _seed_thing_with_location(
+ session, "water well", status, f"A11 {status}"
+ )
+ context.a11_seed_ids[status] = thing.id
+ context.add_cleanup(_teardown_a11_seed_data)
+
+
+@when('the staff member requests items from the "{layer_id}" internal collection')
+def step_when_staff_member_requests_internal_collection_items(context, layer_id):
+ context.response = context.client.get(
+ f"/ogcapi-internal/collections/{layer_id}/items?limit=500"
+ )
+
+
+@then('records with a release_status other than "public" are included in the response')
+def step_then_non_public_records_included(context):
+ payload = context.response.json()
+ ids_present = _layer_feature_ids(payload)
+ non_public_ids = {context.a11_seed_ids["private"], context.a11_seed_ids["draft"]}
+ assert non_public_ids & ids_present, (
+ "expected the internal collection to include the seeded private/draft "
+ f"wells {non_public_ids}, got ids {ids_present}"
+ )
+
+
+@given("the /ogcapi-internal mount has been deployed")
+def step_given_internal_mount_has_been_deployed(context):
+ command.upgrade(_alembic_config(), "head")
+
+
+@when("the database schema is inspected")
+def step_when_database_schema_is_inspected(context):
+ with session_ctx() as session:
+ context.schema_relations = set(
+ session.execute(
+ text(
+ "SELECT c.relname FROM pg_class c "
+ "JOIN pg_namespace n ON n.oid = c.relnamespace "
+ "WHERE c.relkind IN ('v', 'm') AND n.nspname = 'public'"
+ )
+ ).scalars()
+ )
+
+
+@then('the database schema contains relations prefixed with "{prefix}"')
+def step_then_schema_contains_relations_prefixed(context, prefix):
+ matching = {r for r in context.schema_relations if r.startswith(prefix)}
+ assert matching, f"expected at least one relation prefixed {prefix!r}, found none"
+
+
+@then("no ogc_internal_ relation is shared with the public /ogcapi endpoint")
+def step_then_no_internal_relation_shared_with_public(context):
+ internal = {r for r in context.schema_relations if r.startswith("ogc_internal_")}
+ assert internal, "no ogc_internal_ relations found in the schema"
+ for relation in internal:
+ public_equivalent = relation.replace("ogc_internal_", "ogc_", 1)
+ assert public_equivalent in context.schema_relations, (
+ f"{relation} has no distinct public counterpart ({public_equivalent}) in "
+ "the schema -- expected the two sets to coexist as separate relations"
+ )
+
+
+@when("a client requests /ogcapi/collections")
+def step_when_client_requests_ogcapi_collections(context):
+ context.response = context.client.get("/ogcapi/collections")
+
+
+@then('no collection in the response has an id prefixed "{prefix}"')
+def step_then_no_collection_id_prefixed(context, prefix):
+ payload = context.response.json()
+ offending = [c["id"] for c in payload["collections"] if c["id"].startswith(prefix)]
+ assert not offending, f"found collections with id prefixed {prefix!r}: {offending}"
+
+
# ============= EOF =============================================
From 36221e7b95597bfdbf4b283787db1cc0d8d015e4 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 22 Jul 2026 20:18:54 +0000
Subject: [PATCH 013/151] chore: sync staging release-please manifest to v1.2.0
---
.release-please-manifest.staging.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.release-please-manifest.staging.json b/.release-please-manifest.staging.json
index fa8324b78..c3f146397 100644
--- a/.release-please-manifest.staging.json
+++ b/.release-please-manifest.staging.json
@@ -1,3 +1,3 @@
{
- ".": "1.2.0-rc.1"
+ ".": "1.2.0"
}
From 3bee7d50f3537afacd3cb42344e4d67b3dac39ef Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 22 Jul 2026 20:18:58 +0000
Subject: [PATCH 014/151] chore: sync uv.lock to released version 1.2.0
---
uv.lock | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/uv.lock b/uv.lock
index 09ab41b99..3bc143c1f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1552,7 +1552,7 @@ wheels = [
[[package]]
name = "ocotilloapi"
-version = "1.1.5"
+version = "1.2.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
From ed9e4b98df4639646fdad9e9808be2225e5da471 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 27 Jul 2026 15:06:28 +0000
Subject: [PATCH 015/151] build(deps): bump astral-sh/setup-uv from 8.3.2 to
9.0.0
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 8.3.2 to 9.0.0.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v8.3.2...v9.0.0)
---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
dependency-version: 9.0.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
---
.github/workflows/CD_production.yml | 2 +-
.github/workflows/CD_staging.yml | 2 +-
.github/workflows/CD_testing.yml | 2 +-
.github/workflows/forward-merge.yml | 4 ++--
.github/workflows/jira_codex_pr.yml | 2 +-
.github/workflows/tests.yml | 4 ++--
6 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml
index 0017e25a5..d184502d2 100644
--- a/.github/workflows/CD_production.yml
+++ b/.github/workflows/CD_production.yml
@@ -54,7 +54,7 @@ jobs:
ref: refs/tags/${{ env.DEPLOY_TAG }}
- name: Install uv in container
- uses: astral-sh/setup-uv@v8.3.2
+ uses: astral-sh/setup-uv@v9.0.0
with:
version: "latest"
diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml
index 232981071..c17dce995 100644
--- a/.github/workflows/CD_staging.yml
+++ b/.github/workflows/CD_staging.yml
@@ -19,7 +19,7 @@ jobs:
fetch-depth: 0
- name: Install uv in container
- uses: astral-sh/setup-uv@v8.3.2
+ uses: astral-sh/setup-uv@v9.0.0
with:
version: "latest"
diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml
index f9c0ac890..8967e26dc 100644
--- a/.github/workflows/CD_testing.yml
+++ b/.github/workflows/CD_testing.yml
@@ -19,7 +19,7 @@ jobs:
fetch-depth: 0
- name: Install uv in container
- uses: astral-sh/setup-uv@v8.3.2
+ uses: astral-sh/setup-uv@v9.0.0
with:
version: "latest"
diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml
index c84a540c9..d71aa731b 100644
--- a/.github/workflows/forward-merge.yml
+++ b/.github/workflows/forward-merge.yml
@@ -103,7 +103,7 @@ jobs:
# the lockfile is re-locked (see commit 27751110). Idempotent: no
# lockfile change -> no commit.
- name: Install uv
- uses: astral-sh/setup-uv@v8.3.2
+ uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
@@ -166,7 +166,7 @@ jobs:
# push. Plain push (not force) so an out-of-date checkout fails loudly
# instead of clobbering newer hotfix commits.
- name: Install uv
- uses: astral-sh/setup-uv@v8.3.2
+ uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
diff --git a/.github/workflows/jira_codex_pr.yml b/.github/workflows/jira_codex_pr.yml
index ba9f368da..7369a40d4 100644
--- a/.github/workflows/jira_codex_pr.yml
+++ b/.github/workflows/jira_codex_pr.yml
@@ -59,7 +59,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Set up uv (with cache)
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v4
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v4
with:
enable-cache: true
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index a82d54d46..6be087806 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -63,7 +63,7 @@ jobs:
exit 1
- name: Install uv
- uses: astral-sh/setup-uv@v8.3.2
+ uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
@@ -155,7 +155,7 @@ jobs:
exit 1
- name: Install uv
- uses: astral-sh/setup-uv@v8.3.2
+ uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
From 99c1b5d0840de6f6f7e7ef4d613f635185278c9c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 27 Jul 2026 15:06:37 +0000
Subject: [PATCH 016/151] build(deps): bump actions/checkout from 7.0.0 to
7.0.1 in the gha-minor-and-patch group (#793)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the gha-minor-and-patch group with 1 update:
[actions/checkout](https://github.com/actions/checkout).
Updates `actions/checkout` from 7.0.0 to 7.0.1
Release notes
Sourced from actions/checkout's
releases .
v7.0.1
What's Changed
Full Changelog : https://github.com/actions/checkout/compare/v7...v7.0.1
Commits
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/CD_production.yml | 2 +-
.github/workflows/CD_staging.yml | 2 +-
.github/workflows/CD_testing.yml | 2 +-
.github/workflows/format_code.yml | 4 ++--
.github/workflows/forward-merge.yml | 4 ++--
.github/workflows/hotfix-start.yml | 2 +-
.github/workflows/jira_codex_pr.yml | 2 +-
.github/workflows/release-please.yml | 2 +-
.github/workflows/tests.yml | 4 ++--
9 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml
index 0017e25a5..21c6acc07 100644
--- a/.github/workflows/CD_production.yml
+++ b/.github/workflows/CD_production.yml
@@ -46,7 +46,7 @@ jobs:
fi
- name: Check out source repository
- uses: actions/checkout@v7.0.0
+ uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
# Fully-qualified tag ref avoids ambiguity if a branch is ever
diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml
index 232981071..348f2b4b8 100644
--- a/.github/workflows/CD_staging.yml
+++ b/.github/workflows/CD_staging.yml
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Check out source repository
- uses: actions/checkout@v7.0.0
+ uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml
index f9c0ac890..ba4d4e790 100644
--- a/.github/workflows/CD_testing.yml
+++ b/.github/workflows/CD_testing.yml
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Check out source repository
- uses: actions/checkout@v7.0.0
+ uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
diff --git a/.github/workflows/format_code.yml b/.github/workflows/format_code.yml
index 6eb001bed..7e9797129 100644
--- a/.github/workflows/format_code.yml
+++ b/.github/workflows/format_code.yml
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out source repository
- uses: actions/checkout@v7.0.0
+ uses: actions/checkout@v7.0.1
- name: Set up Python environment - 3.12
uses: actions/setup-python@v7.0.0
with:
@@ -34,7 +34,7 @@ jobs:
contents: write
pull-requests: write
steps:
- - uses: actions/checkout@v7.0.0
+ - uses: actions/checkout@v7.0.1
with:
ref: ${{ github.head_ref }}
- uses: psf/black@stable
diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml
index c84a540c9..ccb788edf 100644
--- a/.github/workflows/forward-merge.yml
+++ b/.github/workflows/forward-merge.yml
@@ -54,7 +54,7 @@ jobs:
GH_TOKEN: ${{ secrets.FORWARD_MERGE_TOKEN || github.token }}
TAG: ${{ inputs.tag_name }}
steps:
- - uses: actions/checkout@v7.0.0
+ - uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
token: ${{ secrets.FORWARD_MERGE_TOKEN || github.token }}
@@ -149,7 +149,7 @@ jobs:
TAG: ${{ inputs.tag_name }}
SOURCE: ${{ inputs.source_branch }}
steps:
- - uses: actions/checkout@v7.0.0
+ - uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
ref: ${{ inputs.source_branch }}
diff --git a/.github/workflows/hotfix-start.yml b/.github/workflows/hotfix-start.yml
index 6f1a81e4c..bec2fcbcc 100644
--- a/.github/workflows/hotfix-start.yml
+++ b/.github/workflows/hotfix-start.yml
@@ -24,7 +24,7 @@ jobs:
create-hotfix-branch:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v7.0.0
+ - uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
diff --git a/.github/workflows/jira_codex_pr.yml b/.github/workflows/jira_codex_pr.yml
index ba9f368da..36a5a8841 100644
--- a/.github/workflows/jira_codex_pr.yml
+++ b/.github/workflows/jira_codex_pr.yml
@@ -41,7 +41,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
with:
fetch-depth: 0
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
index bac76bf49..3b32a2cd9 100644
--- a/.github/workflows/release-please.yml
+++ b/.github/workflows/release-please.yml
@@ -37,7 +37,7 @@ jobs:
# configs set include-v-in-tag: true, so the tag is `v`. Prefer
# the action's own output if it is ever non-empty.
- if: ${{ steps.release.outputs.release_created == 'true' }}
- uses: actions/checkout@v7.0.0
+ uses: actions/checkout@v7.0.1
- id: resolve_tag
if: ${{ steps.release.outputs.release_created == 'true' }}
env:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index a82d54d46..57883f562 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -49,7 +49,7 @@ jobs:
steps:
- name: Check out source repository
- uses: actions/checkout@v7.0.0
+ uses: actions/checkout@v7.0.1
- name: Wait for database readiness
run: |
@@ -141,7 +141,7 @@ jobs:
steps:
- name: Check out source repository
- uses: actions/checkout@v7.0.0
+ uses: actions/checkout@v7.0.1
- name: Wait for database readiness
run: |
From 2ddffd7e973a2cb734bc8656a8a5d7a20fd6cf84 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 27 Jul 2026 15:22:35 +0000
Subject: [PATCH 017/151] build(deps): bump the uv-non-major group with 18
updates (#795)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the uv-non-major group with 18 updates:
| Package | From | To |
| --- | --- | --- |
| [aiohttp](https://github.com/aio-libs/aiohttp) | `3.14.1` | `3.14.3` |
| [annotated-types](https://github.com/annotated-types/annotated-types)
| `0.7.0` | `0.8.0` |
| [cachetools](https://github.com/tkem/cachetools) | `7.1.4` | `7.1.6` |
| [certifi](https://github.com/certifi/python-certifi) | `2026.6.17` |
`2026.7.22` |
|
[cloud-sql-python-connector](https://github.com/GoogleCloudPlatform/cloud-sql-python-connector)
| `1.20.4` | `1.21.0` |
| [fastapi](https://github.com/fastapi/fastapi) | `0.139.2` | `0.140.2`
|
| [google-api-core](https://github.com/googleapis/google-cloud-python) |
`2.32.0` | `2.33.0` |
| [google-auth](https://github.com/googleapis/google-cloud-python) |
`2.56.0` | `2.56.2` |
| [greenlet](https://github.com/python-greenlet/greenlet) | `3.5.3` |
`3.5.4` |
| [phonenumbers](https://github.com/daviddrysdale/python-phonenumbers) |
`9.0.34` | `9.0.35` |
| [proto-plus](https://github.com/googleapis/google-cloud-python) |
`1.28.1` | `1.28.2` |
| [pytz](https://github.com/stub42/pytz) | `2026.2` | `2026.3.post1` |
| [scramp](https://github.com/tlocke/scramp) | `1.4.12` | `1.4.15` |
| [sentry-sdk[fastapi]](https://github.com/getsentry/sentry-python) |
`2.66.0` | `2.66.1` |
| [pre-commit](https://github.com/pre-commit/pre-commit) | `4.6.0` |
`4.6.1` |
|
[google-api-python-client](https://github.com/googleapis/google-api-python-client)
| `2.184.0` | `2.198.0` |
| [filelock](https://github.com/tox-dev/py-filelock) | `3.31.1` |
`3.32.0` |
| [sentry-sdk](https://github.com/getsentry/sentry-python) | `2.66.0` |
`2.66.1` |
Updates `aiohttp` from 3.14.1 to 3.14.3
Updates `annotated-types` from 0.7.0 to 0.8.0
Release notes
Sourced from annotated-types's
releases .
v0.8.0
What's Changed
New Contributors
Full Changelog : https://github.com/annotated-types/annotated-types/compare/v0.7.0...v0.8.0
Commits
Updates `cachetools` from 7.1.4 to 7.1.6
Changelog
Sourced from cachetools's
changelog .
v7.1.6 (2026-07-24)
Minor style improvements to keep ruff happy.
v7.1.5 (2026-07-23)
Fix TLRUCache silently keeping stale values on expired
overwrites.
Reject negative cache item getsizeof values.
Update build environment.
Commits
13bb86a
Minor style improvements to keep ruff happy.
e2250be
Fix RTD version handling.
0d2a6ea
Release v7.1.5.
d64cf80
Prepare v7.1.5.
fcbb0de
Fix #406 :
Merge branch 'gaoflow-fix-tlru-overwrite-expired-stale-value' into
...
c0fdf6a
Fix TLRUCache silently keeping stale value on expired overwrite
978d34d
Bump actions/setup-python from 6.2.0 to 6.3.0
d5c7eea
Reject negative cache item sizes
578e976
Update build environment.
e164b70
Bump codecov/codecov-action from 6.0.0 to 7.0.0
Additional commits viewable in compare
view
Updates `certifi` from 2026.6.17 to 2026.7.22
Commits
Updates `cloud-sql-python-connector` from 1.20.4 to 1.21.0
Release notes
Sourced from cloud-sql-python-connector's
releases .
v1.21.0
1.21.0
(2026-07-23)
Features
Add PSC DNS and Global Write Endpoint support to Python Connector
(#1424 )
(b34e9c6 )
Bug Fixes
Changelog
Sourced from cloud-sql-python-connector's
changelog .
1.21.0
(2026-07-23)
Features
Add PSC DNS and Global Write Endpoint support to Python Connector
(#1424 )
(b34e9c6 )
Bug Fixes
Commits
Updates `fastapi` from 0.139.2 to 0.140.2
Release notes
Sourced from fastapi's
releases .
0.140.2
Refactors
Internal
0.140.1
Refactors
♻️ Update the lru_cache limit for dependencies to account for large
apps. PR #16062
by @tiangolo .
0.140.0
Refactors
Docs
Internal
Commits
Updates `google-api-core` from 2.32.0 to 2.33.0
Release notes
Sourced from google-api-core's
releases .
google-api-core: v2.33.0
2.33.0
(2026-07-22)
Features
api_core: add request-id auto-population logic to
gapic_v1 public helpers (#17738 )
(68e1313 )
api-core: add get_universe_domain helper to
universe.py (#17799 )
(d461da7 )
Bug Fixes
api-core: prevent overwriting explicit empty
strings for optional request_id (#17798 )
(07f7503 )
Commits
6702c7a
chore: release main (#17809 )
6ab2fe1
chore(main): release google-cloud-productregistry 0.1.0 (#17806 )
cc109ca
chore(main): release google-auth 2.56.2 (#17814 )
9668675
chore: update librarian to v0.28.0 (#17811 )
d4b76fa
chore(sqlalchemy-bigquery): restore complete coverage (#17770 )
df0541a
fix(gapic): mock os.path.exists in mTLS tests to support newer google
auth (#...
1a10e23
chore(main): release google-maps-isochrones 0.1.0 (#17804 )
2a2dc83
test(bigquery): add debug info to socket leak test (#17802 )
b2d3fb9
chore(handwritten): centralize mypy configuration and update handwritten
pack...
2a7d346
tests: remove protobuf cpp from tests which is no longer used as of
Protobuf ...
Additional commits viewable in compare
view
Updates `google-auth` from 2.56.0 to 2.56.2
Release notes
Sourced from google-auth's
releases .
google-auth: v2.56.2
2.56.2
(2026-07-21)
Bug Fixes
auth: centralize cert discovery logic and steps (#17696 )
(edc0423 )
auth: exit early when agent cert config is outside
well-known directory (#17762 )
(61e795a )
transport: propagate mTLS adapter to auth session
and fix connection leaks (#17689 )
(8289d32 )
update _SERVICE_ACCOUNT_EMAIL_PATTERN to require
.gserviceaccount.com suffix (#17748 )
(b60bb04 )
google-auth: v2.56.1
2.56.1
(2026-07-17)
Bug Fixes
Commits
cc109ca
chore(main): release google-auth 2.56.2 (#17814 )
d4b76fa
chore(sqlalchemy-bigquery): restore complete coverage (#17770 )
df0541a
fix(gapic): mock os.path.exists in mTLS tests to support newer google
auth (#...
1a10e23
chore(main): release google-maps-isochrones 0.1.0 (#17804 )
2a2dc83
test(bigquery): add debug info to socket leak test (#17802 )
b2d3fb9
chore(handwritten): centralize mypy configuration and update handwritten
pack...
2a7d346
tests: remove protobuf cpp from tests which is no longer used as of
Protobuf ...
fb5aada
chore(main): release google-cloud-commerceproducer 0.1.0 (#17805 )
b60bb04
fix: update _SERVICE_ACCOUNT_EMAIL_PATTERN to require
.gserviceaccount.com ...
ed25698
tests(sqlalchemy-bigquery): resolve ST function type binding bug (#17769 )
Additional commits viewable in compare
view
Updates `greenlet` from 3.5.3 to 3.5.4
Changelog
Sourced from greenlet's
changelog .
3.5.4 (2026-07-22)
Fix a crash (segfault) on free-threaded builds of Python 3.14 and
later when the garbage collector runs while a greenlet that was
started from a non-empty C-stack-reference state is active.
See issue 515
<https://github.com/python-greenlet/greenlet/issues/515>_.
Thanks to ddorian and Kumar Aditya.
Fix a potential use-after-free on free-threaded builds of Python 3.14
and later when the garbage collector runs while a greenlet is
suspended holding a _PyCStackRef (for example, mid
attribute
resolution). See
issue 515
<https://github.com/python-greenlet/greenlet/issues/515>_.
Thanks to ddorian and Kumar Aditya.
Fix a deadlock on free-threaded builds when a greenlet switch
happened
while a PyCriticalSection was held -- for example inside
asyncio's
Task.__step, which holds one on the running task for the
duration of
the step. See PR 519
<https://github.com/python-greenlet/greenlet/pull/519/>.
Thanks to ddorian and Kumar Aditya.
.. note::
Binary 3.15 wheels are now built with Python 3.15b4. These may not
be compatible with earlier or later versions of 3.15. Binary
3.15 wheels of greenlet from previous releases (e.g., 3.5.3)
may not be compatible with Python 3.15b4.
Commits
384be88
Preparing release 3.5.4
bbdf57b
Add note to CHANGES about versions of 3.15 binary wheels may/not be
compatibl...
7599023
Merge pull request #517
from ddorian/issue515-c-stack-refs
73a0a1a
Fix the suspended C-stack-ref GC test to catch its regression
1ff35f4
Merge branch 'master' into issue515-c-stack-refs
847fb82
Merge pull request #520
from dynapx/fix-test-extension-npd
0b471ba
Merge pull request #519
from ddorian/freethread-switch-critical-section
d55914d
Simplify the CHANGES.rst entry
78932dc
Change notes are for end users, they don't need to go into technical
detail
39bf70c
Hold the C-stack ref snapshot in a std::vector<OwnedObject>
Additional commits viewable in compare
view
Updates `phonenumbers` from 9.0.34 to 9.0.35
Commits
Updates `proto-plus` from 1.28.1 to 1.28.2
Release notes
Sourced from proto-plus's
releases .
proto-plus: v1.28.2
1.28.2
(2026-07-22)
Bug Fixes
proto-plus: make Marshal thread-safe and handle
race conditions (#17774 )
(0719f1e ),
closes #15100
Commits
6702c7a
chore: release main (#17809 )
6ab2fe1
chore(main): release google-cloud-productregistry 0.1.0 (#17806 )
cc109ca
chore(main): release google-auth 2.56.2 (#17814 )
9668675
chore: update librarian to v0.28.0 (#17811 )
d4b76fa
chore(sqlalchemy-bigquery): restore complete coverage (#17770 )
df0541a
fix(gapic): mock os.path.exists in mTLS tests to support newer google
auth (#...
1a10e23
chore(main): release google-maps-isochrones 0.1.0 (#17804 )
2a2dc83
test(bigquery): add debug info to socket leak test (#17802 )
b2d3fb9
chore(handwritten): centralize mypy configuration and update handwritten
pack...
2a7d346
tests: remove protobuf cpp from tests which is no longer used as of
Protobuf ...
Additional commits viewable in compare
view
Updates `pytz` from 2026.2 to 2026.3.post1
Commits
661bca9
Bump version numbers to 2026.3.post1 for python2 fix
1e31a16
Log python version running tests, force python2
b3ca7c3
Unix line endings
b55039a
Replace non-ASCII character in comment to fix build with Python 2
5420ee2
Replace non-ASCII character in comment
2c139e8
Merge branch 'fix/localize-overflow-at-datetime-extremes' of https://github.c ...
c843864
Run zdump tests quietly
518500c
Reduce noise when collecting zdump info dumps
081f935
Merge branch 'kytta-fix-dst' into 2026c
8c9d69b
Merge branch 'master' into 2026c
Additional commits viewable in compare
view
Updates `scramp` from 1.4.12 to 1.4.15
Commits
Updates `sentry-sdk[fastapi]` from 2.66.0 to 2.66.1
Release notes
Sourced from sentry-sdk[fastapi]'s
releases .
2.66.1
Bug Fixes 🐛
Tracing
Handle exceptions raised within traces_sampler and other callbacks
by @ericapisani in
#6853
Internal Changes 🔧
Changelog
Sourced from sentry-sdk[fastapi]'s
changelog .
2.66.1
Bug Fixes 🐛
Tracing
Handle exceptions raised within traces_sampler and other callbacks
by @ericapisani in
#6853
Internal Changes 🔧
Commits
653a292
Update CHANGELOG.md
e20c226
release: 2.66.1
4524b65
fix(tracing): Stop setting NoOpSpan on scope in the
streaming trace lifecyc...
2e9f26e
ref(tracing): No-op and emit warning in start_transaction
with the streamin...
3a50950
fix(tracing): handle exceptions raised within traces_sampler and other
callba...
b5171b7
ref: Use top-level trace_lifecycle and
ignore_spans options in tests (#6855 )
17e0348
ref: Use old sampling context format in span streaming (#6848 )
2e09497
test: Add streaming tests to test_http_headers (#6785 )
9996734
feat(tracing): Send sentry.segment.name.source instead of
`sentry.span.sour...
00224f7
remove span streaming docs in changelog (#6831 )
Additional commits viewable in compare
view
Updates `pre-commit` from 4.6.0 to 4.6.1
Release notes
Sourced from pre-commit's
releases .
pre-commit v4.6.1
Fixes
Install language: node hooks via git.
Set JULIA_DEPOT_PATH for language: julia.
Produce error on mistyped --repo for pre-commit
autoupdate.
Improve performance of commit existence check in
pre-push.
Avoid duplicating conflicted filenames during pre-commit run
--all-files.
Changelog
Sourced from pre-commit's
changelog .
4.6.1 - 2026-07-21
Fixes
Install language: node hooks via git.
Set JULIA_DEPOT_PATH for language: julia.
Produce error on mistyped --repo for pre-commit
autoupdate.
Improve performance of commit existence check in
pre-push.
Avoid duplicating conflicted filenames during pre-commit run
--all-files.
Commits
242ce8a
v4.6.1
766e550
Merge pull request #3727
from pre-commit/dedupe
1558d06
Merge pull request #3726
from pre-commit/exists-faster
8a1c47a
avoid duplicate files in --all-files during conflict
2e01c99
faster check of rev existing locally as a commit
3613bf2
Merge pull request #3701
from pre-commit/autoupdate-repos
1d811d9
Return an error for invalid --repo
374d354
Merge pull request #3711
from damonbayer/dmb_JULIA_DEPOT_PATH
1e7994f
set JULIA_DEPOT_PATH
b2b9119
Merge pull request #3719
from pre-commit/npm-unknown-options
Additional commits viewable in compare
view
Updates `google-api-python-client` from 2.184.0 to 2.198.0
Release notes
Sourced from google-api-python-client's
releases .
v2.198.0
Features
adexchangebuyer2: Update the api https://togithub.com/googleapis/google-api-python-client/commit/a6b7a3c1312a0b843c6451f1502268232af1e08d
(ea0e936 )
admin: Update the api https://togithub.com/googleapis/google-api-python-client/commit/39a7f8e64940d318ce50b5431ebca0ca6f044b4e
(ea0e936 )
agentregistry: Update the api https://togithub.com/googleapis/google-api-python-client/commit/47cc6885a57ff256a2878b8902f66f4d97e980be
(ea0e936 )
aiplatform: Update the api https://togithub.com/googleapis/google-api-python-client/commit/bf9010bd3c1f6825858612032ad8722e53368738
(ea0e936 )
alertcenter: Update the api https://togithub.com/googleapis/google-api-python-client/commit/60ed501536c048d4257f05fb44d01ac90afc45c6
(ea0e936 )
alloydb: Update the api https://togithub.com/googleapis/google-api-python-client/commit/07ab81d00d9225efc0d1bfb54d7a2131537013bb
(ea0e936 )
analyticsadmin: Update the api https://togithub.com/googleapis/google-api-python-client/commit/d13cd0d5ec973a994a6409e4ccdd879ca5cdec24
(ea0e936 )
analyticshub: Update the api https://togithub.com/googleapis/google-api-python-client/commit/29d33229a8aca59cc2105ca57f10c5c34392eedf
(ea0e936 )
androidmanagement: Update the api https://togithub.com/googleapis/google-api-python-client/commit/0b2dcfba95c75409fcebe62b45d481e2b9cee79c
(ea0e936 )
androidpublisher: Update the api https://togithub.com/googleapis/google-api-python-client/commit/1dc37cdcddd779ca435abc9593b0bfb4e298770d
(ea0e936 )
artifactregistry: Update the api https://togithub.com/googleapis/google-api-python-client/commit/001dba95de25008758d1955c7ff5db51e48606a9
(ea0e936 )
assuredworkloads: Update the api https://togithub.com/googleapis/google-api-python-client/commit/2982b1986becfc8cf17768a86146cfac04338f81
(ea0e936 )
backupdr: Update the api https://togithub.com/googleapis/google-api-python-client/commit/0b64d9bb3d591fd521b86b21fdbc37f3dd793f43
(ea0e936 )
bigqueryconnection: Update the api https://togithub.com/googleapis/google-api-python-client/commit/8e6a3e697448de8fd7128240f2615228db24e85c
(ea0e936 )
bigquerydatatransfer: Update the api https://togithub.com/googleapis/google-api-python-client/commit/012db99dae421f7b21f0e43b1df333b209cf3e15
(ea0e936 )
bigquery: Update the api https://togithub.com/googleapis/google-api-python-client/commit/9548dcf1aa1eeaa6524b6d9386178dac69c39096
(ea0e936 )
calendar: Update the api https://togithub.com/googleapis/google-api-python-client/commit/399252cfd1bed4193f5821e66536d6a3a75f03c8
(ea0e936 )
ces: Update the api https://togithub.com/googleapis/google-api-python-client/commit/2716cb2b0eb0d91686901d5153aa9581d1143bb4
(ea0e936 )
chat: Update the api https://togithub.com/googleapis/google-api-python-client/commit/0ae828d973d7bb7f3e6f86bd4338b11b9f3c3e8f
(ea0e936 )
chromemanagement: Update the api https://togithub.com/googleapis/google-api-python-client/commit/7afd85a6912a8239b3c72ca765db28487348222a
(ea0e936 )
chromewebstore: Update the api https://togithub.com/googleapis/google-api-python-client/commit/83fbd762d4539251e84ebd6af38a770c73027da7
(ea0e936 )
cloudbuild: Update the api https://togithub.com/googleapis/google-api-python-client/commit/c38d7967d2acd8762a0b803592d331c11627f750
(ea0e936 )
clouddeploy: Update the api https://togithub.com/googleapis/google-api-python-client/commit/28abab7bb30a59f9487cb693ad90799ccaabdf7d
(ea0e936 )
cloudkms: Update the api https://togithub.com/googleapis/google-api-python-client/commit/fead0039902e25ada74a65b06d430e8cfa7233f5
(ea0e936 )
cloudsupport: Update the api https://togithub.com/googleapis/google-api-python-client/commit/81e568808c282c0cc8901eed0551bd8b67b5a655
(=3.13"
dependencies = [
"aiofiles==24.1.0",
"aiohappyeyeballs==2.7.1",
- "aiohttp==3.14.1",
+ "aiohttp==3.14.3",
"aiosignal==1.4.0",
"aiosqlite==0.22.1",
"alembic==1.18.5",
- "annotated-types==0.7.0",
+ "annotated-types==0.8.0",
"anyio==4.14.2",
"apitally[fastapi]==0.25.1",
"asgiref==3.12.1",
@@ -20,28 +20,28 @@ dependencies = [
"attrs==26.1.0",
"authlib==1.7.2",
"bcrypt==4.3.0",
- "cachetools==7.1.4",
- "certifi==2026.6.17",
+ "cachetools==7.1.6",
+ "certifi==2026.7.22",
"cffi==2.1.0",
"charset-normalizer==3.4.9",
"click==8.4.2",
- "cloud-sql-python-connector==1.20.4",
+ "cloud-sql-python-connector==1.21.0",
"cryptography==48.0.1",
"dnspython==2.8.0",
"dotenv==0.9.9",
"email-validator==2.3.0",
- "fastapi==0.139.2",
+ "fastapi==0.140.2",
"fastapi-pagination==0.15.15",
"frozenlist==1.8.0",
"geoalchemy2==0.20.0",
- "google-api-core==2.32.0",
- "google-auth==2.56.0",
+ "google-api-core==2.33.0",
+ "google-auth==2.56.2",
"google-cloud-core==2.6.0",
"google-cloud-storage==3.13.0",
"google-crc32c==1.8.0",
"google-resumable-media==2.10.0",
"googleapis-common-protos==1.75.0",
- "greenlet==3.5.3",
+ "greenlet==3.5.4",
"gunicorn==23.0.0",
"h11==0.16.0",
"httpcore==1.0.9",
@@ -58,11 +58,11 @@ dependencies = [
"pandas==2.3.2",
"pandas-stubs~=2.3.2",
"pg8000==1.31.5",
- "phonenumbers==9.0.34",
+ "phonenumbers==9.0.35",
"pillow==12.3.0",
"pluggy==1.6.0",
"propcache==0.5.2",
- "proto-plus==1.28.1",
+ "proto-plus==1.28.2",
"protobuf==6.33.5",
"psycopg2-binary>=2.9.12",
"pyasn1==0.6.4",
@@ -78,11 +78,11 @@ dependencies = [
"python-dateutil==2.9.0.post0",
"python-jose>=3.5.0",
"python-multipart==0.0.32",
- "pytz==2026.2",
+ "pytz==2026.3.post1",
"requests==2.34.2",
"rsa==4.9.1",
- "scramp==1.4.12",
- "sentry-sdk[fastapi]==2.66.0",
+ "scramp==1.4.15",
+ "sentry-sdk[fastapi]==2.66.1",
"shapely==2.1.2",
"six==1.17.0",
"sniffio==1.3.1",
@@ -136,7 +136,7 @@ dev = [
"black>=26.5.1",
"faker>=25.0.0",
"flake8>=7.3.0",
- "pre-commit>=4.6.0",
+ "pre-commit>=4.6.1",
"pyhamcrest>=2.0.3",
"pytest>=9.1.1",
"pytest-cov>=6.2.1",
@@ -149,7 +149,7 @@ dev = [
# --no-dev`). CI installs them explicitly with `uv sync --group cli`.
cli = [
"openpyxl==3.1.5",
- "google-api-python-client==2.184.0",
+ "google-api-python-client==2.198.0",
]
[tool.pytest.ini_options]
diff --git a/requirements.txt b/requirements.txt
index a552516d0..68b1c0770 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,72 +16,126 @@ aiohappyeyeballs==2.7.1 \
# via
# aiohttp
# ocotilloapi
-aiohttp==3.14.1 \
- --hash=sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983 \
- --hash=sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340 \
- --hash=sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d \
- --hash=sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a \
- --hash=sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4 \
- --hash=sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8 \
- --hash=sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397 \
- --hash=sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8 \
- --hash=sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09 \
- --hash=sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba \
- --hash=sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf \
- --hash=sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5 \
- --hash=sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035 \
- --hash=sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6 \
- --hash=sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35 \
- --hash=sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333 \
- --hash=sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203 \
- --hash=sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1 \
- --hash=sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251 \
- --hash=sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365 \
- --hash=sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94 \
- --hash=sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da \
- --hash=sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe \
- --hash=sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080 \
- --hash=sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397 \
- --hash=sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9 \
- --hash=sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345 \
- --hash=sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3 \
- --hash=sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602 \
- --hash=sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444 \
- --hash=sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15 \
- --hash=sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe \
- --hash=sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2 \
- --hash=sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496 \
- --hash=sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876 \
- --hash=sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448 \
- --hash=sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6 \
- --hash=sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd \
- --hash=sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f \
- --hash=sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c \
- --hash=sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca \
- --hash=sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c \
- --hash=sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa \
- --hash=sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc \
- --hash=sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0 \
- --hash=sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0 \
- --hash=sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844 \
- --hash=sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719 \
- --hash=sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1 \
- --hash=sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178 \
- --hash=sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95 \
- --hash=sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96 \
- --hash=sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1 \
- --hash=sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a \
- --hash=sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588 \
- --hash=sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec \
- --hash=sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004 \
- --hash=sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780 \
- --hash=sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d \
- --hash=sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca \
- --hash=sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665 \
- --hash=sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296 \
- --hash=sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c \
- --hash=sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451 \
- --hash=sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3
+aiohttp==3.14.3 \
+ --hash=sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39 \
+ --hash=sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043 \
+ --hash=sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b \
+ --hash=sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d \
+ --hash=sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf \
+ --hash=sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f \
+ --hash=sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7 \
+ --hash=sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc \
+ --hash=sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559 \
+ --hash=sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f \
+ --hash=sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929 \
+ --hash=sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147 \
+ --hash=sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9 \
+ --hash=sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8 \
+ --hash=sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf \
+ --hash=sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7 \
+ --hash=sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8 \
+ --hash=sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85 \
+ --hash=sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30 \
+ --hash=sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553 \
+ --hash=sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7 \
+ --hash=sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86 \
+ --hash=sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e \
+ --hash=sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a \
+ --hash=sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c \
+ --hash=sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da \
+ --hash=sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5 \
+ --hash=sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d \
+ --hash=sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100 \
+ --hash=sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71 \
+ --hash=sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22 \
+ --hash=sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1 \
+ --hash=sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479 \
+ --hash=sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb \
+ --hash=sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062 \
+ --hash=sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661 \
+ --hash=sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427 \
+ --hash=sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32 \
+ --hash=sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a \
+ --hash=sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db \
+ --hash=sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42 \
+ --hash=sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a \
+ --hash=sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd \
+ --hash=sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06 \
+ --hash=sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8 \
+ --hash=sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228 \
+ --hash=sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0 \
+ --hash=sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919 \
+ --hash=sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee \
+ --hash=sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2 \
+ --hash=sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f \
+ --hash=sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43 \
+ --hash=sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098 \
+ --hash=sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c \
+ --hash=sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371 \
+ --hash=sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b \
+ --hash=sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0 \
+ --hash=sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f \
+ --hash=sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100 \
+ --hash=sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529 \
+ --hash=sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc \
+ --hash=sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c \
+ --hash=sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41 \
+ --hash=sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716 \
+ --hash=sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33 \
+ --hash=sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f \
+ --hash=sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e \
+ --hash=sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa \
+ --hash=sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b \
+ --hash=sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80 \
+ --hash=sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646 \
+ --hash=sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e \
+ --hash=sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b \
+ --hash=sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c \
+ --hash=sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963 \
+ --hash=sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae \
+ --hash=sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25 \
+ --hash=sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c \
+ --hash=sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f \
+ --hash=sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807 \
+ --hash=sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a \
+ --hash=sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f \
+ --hash=sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d \
+ --hash=sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82 \
+ --hash=sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15 \
+ --hash=sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0 \
+ --hash=sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d \
+ --hash=sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9 \
+ --hash=sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19 \
+ --hash=sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239 \
+ --hash=sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0 \
+ --hash=sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c \
+ --hash=sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5 \
+ --hash=sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b \
+ --hash=sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4 \
+ --hash=sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2 \
+ --hash=sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9 \
+ --hash=sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0 \
+ --hash=sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883 \
+ --hash=sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d \
+ --hash=sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d \
+ --hash=sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6 \
+ --hash=sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3 \
+ --hash=sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924 \
+ --hash=sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde \
+ --hash=sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787 \
+ --hash=sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb \
+ --hash=sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b \
+ --hash=sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0 \
+ --hash=sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910 \
+ --hash=sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9 \
+ --hash=sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627 \
+ --hash=sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48 \
+ --hash=sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b \
+ --hash=sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce \
+ --hash=sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a \
+ --hash=sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0 \
+ --hash=sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24 \
+ --hash=sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5
# via
# cloud-sql-python-connector
# ocotilloapi
@@ -105,9 +159,9 @@ annotated-doc==0.0.4 \
# via
# fastapi
# typer
-annotated-types==0.7.0 \
- --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \
- --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89
+annotated-types==0.8.0 \
+ --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
+ --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
# via
# ocotilloapi
# pydantic
@@ -231,13 +285,13 @@ blinker==1.9.0 \
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
# via flask
-cachetools==7.1.4 \
- --hash=sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54 \
- --hash=sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6
+cachetools==7.1.6 \
+ --hash=sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096 \
+ --hash=sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1
# via ocotilloapi
-certifi==2026.6.17 \
- --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \
- --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db
+certifi==2026.7.22 \
+ --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
+ --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
# via
# httpcore
# httpx
@@ -462,9 +516,9 @@ cligj==0.7.2 \
--hash=sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27 \
--hash=sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df
# via rasterio
-cloud-sql-python-connector==1.20.4 \
- --hash=sha256:4c1cd8b573d5e9b93a6f390ccf772fa431afdcc32025b1577e2bafa89756a9f6 \
- --hash=sha256:fe2dbee747543ad2c720760c53064f0ef42ed04218981e1f7231362a88b3cf44
+cloud-sql-python-connector==1.21.0 \
+ --hash=sha256:104e47d1a06448ec1231cf76454cb474f8c1954f970c7c2931b1aa2088805d8d \
+ --hash=sha256:a5295627caa588c5c4b7b718d1954b8cf43de1dba9749b60b756984a1ea9cb21
# via ocotilloapi
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
@@ -544,9 +598,9 @@ email-validator==2.3.0 \
--hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \
--hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426
# via ocotilloapi
-fastapi==0.139.2 \
- --hash=sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e \
- --hash=sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c
+fastapi==0.140.2 \
+ --hash=sha256:5f64faeb12339d783510db0498da660539195ac7b5bdf69c0fc558f4580a9724 \
+ --hash=sha256:944336ef298148dfd97478638567b223d929aba8717ad8be73ae962a62ecd61d
# via
# apitally
# fastapi-pagination
@@ -556,9 +610,9 @@ fastapi-pagination==0.15.15 \
--hash=sha256:d6e9e4bc4d6e20709dcabc11b16056cd5cd184c995ee214b0190f6b81426fa0c \
--hash=sha256:dc828d7cd15614c650c284bd2c3a98a8a2d9ce340508be3970dc8986908a02aa
# via ocotilloapi
-filelock==3.31.1 \
- --hash=sha256:9e0c4e88ebe90833c1beafd3a547ccbc0bf7f491cd3858c3ec7aed63efe02163 \
- --hash=sha256:9ea33146c780161bf67cb20c7cb26b651566820d65ad8dfdd79422602a2dcfc0
+filelock==3.32.0 \
+ --hash=sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402 \
+ --hash=sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3
# via pygeoapi
flask==3.1.3 \
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
@@ -639,16 +693,16 @@ geoalchemy2==0.20.0 \
--hash=sha256:1489a1d106519542a79c97cd0b4c537d80462c353610ebc2429cf2c43daac717 \
--hash=sha256:450f427f4bc3cf2d5ddee0af3763aed0f3eea2384e7c9a99798d8f1508279322
# via ocotilloapi
-google-api-core==2.32.0 \
- --hash=sha256:2b33aad226b19272458c46abfe5c5a38d9531ece0c44502129a1463ce83674ac \
- --hash=sha256:ae1f0d58a6c8869350bf469f8eb3092e7f8c494a942d9525494afb6c162b0904
+google-api-core==2.33.0 \
+ --hash=sha256:3a36bcc3e319783f4c97da41f6f45ea6ffcaa55848e341de16e09cb70243c2bb \
+ --hash=sha256:a2e22a0c1d0f03eafff1858b38cf46f832d5902b0c052235bf0ab8402929fbdc
# via
# google-cloud-core
# google-cloud-storage
# ocotilloapi
-google-auth==2.56.0 \
- --hash=sha256:6e88c10217e07a92bfd01cac8ee99e32ccfb08414c3102e6c5b8d58f37a0d1e0 \
- --hash=sha256:f90fa030b569a92654b9d690665a073841df33d57487be53db583a9a0867a553
+google-auth==2.56.2 \
+ --hash=sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6 \
+ --hash=sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051
# via
# cloud-sql-python-connector
# google-api-core
@@ -693,86 +747,86 @@ googleapis-common-protos==1.75.0 \
# via
# google-api-core
# ocotilloapi
-greenlet==3.5.3 \
- --hash=sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db \
- --hash=sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7 \
- --hash=sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8 \
- --hash=sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc \
- --hash=sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da \
- --hash=sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8 \
- --hash=sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91 \
- --hash=sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c \
- --hash=sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310 \
- --hash=sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3 \
- --hash=sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce \
- --hash=sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149 \
- --hash=sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d \
- --hash=sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34 \
- --hash=sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be \
- --hash=sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2 \
- --hash=sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b \
- --hash=sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0 \
- --hash=sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71 \
- --hash=sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d \
- --hash=sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04 \
- --hash=sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227 \
- --hash=sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c \
- --hash=sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6 \
- --hash=sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8 \
- --hash=sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21 \
- --hash=sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23 \
- --hash=sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605 \
- --hash=sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128 \
- --hash=sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f \
- --hash=sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea \
- --hash=sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81 \
- --hash=sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2 \
- --hash=sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8 \
- --hash=sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e \
- --hash=sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb \
- --hash=sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47 \
- --hash=sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8 \
- --hash=sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab \
- --hash=sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7 \
- --hash=sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861 \
- --hash=sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702 \
- --hash=sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814 \
- --hash=sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf \
- --hash=sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a \
- --hash=sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260 \
- --hash=sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1 \
- --hash=sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec \
- --hash=sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a \
- --hash=sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5 \
- --hash=sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1 \
- --hash=sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4 \
- --hash=sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c \
- --hash=sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda \
- --hash=sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb \
- --hash=sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31 \
- --hash=sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d \
- --hash=sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c \
- --hash=sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d \
- --hash=sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b \
- --hash=sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550 \
- --hash=sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c \
- --hash=sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357 \
- --hash=sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c \
- --hash=sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4 \
- --hash=sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930 \
- --hash=sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8 \
- --hash=sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b \
- --hash=sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb \
- --hash=sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16 \
- --hash=sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608 \
- --hash=sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f \
- --hash=sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc \
- --hash=sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d \
- --hash=sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44 \
- --hash=sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b \
- --hash=sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3 \
- --hash=sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154 \
- --hash=sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117
+greenlet==3.5.4 \
+ --hash=sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20 \
+ --hash=sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c \
+ --hash=sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994 \
+ --hash=sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8 \
+ --hash=sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d \
+ --hash=sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9 \
+ --hash=sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f \
+ --hash=sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809 \
+ --hash=sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c \
+ --hash=sha256:188e4d142f243051d92a1f5c244a741da02dddc070a0620c842804d7b56d008c \
+ --hash=sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72 \
+ --hash=sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3 \
+ --hash=sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02 \
+ --hash=sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c \
+ --hash=sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c \
+ --hash=sha256:2cdaadc3d31445a8f782bde3cd37e49a2c2a9c6da6daf76a3e34c683b271a3c7 \
+ --hash=sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec \
+ --hash=sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c \
+ --hash=sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686 \
+ --hash=sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861 \
+ --hash=sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8 \
+ --hash=sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0 \
+ --hash=sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4 \
+ --hash=sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9 \
+ --hash=sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3 \
+ --hash=sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9 \
+ --hash=sha256:4ab9f0704bccf6d3b38e0d2130b7b33271cff11453690da074fa280c3aa8e8e7 \
+ --hash=sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7 \
+ --hash=sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd \
+ --hash=sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3 \
+ --hash=sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2 \
+ --hash=sha256:69173331fbc5d64bfac0065d7e22c39cfcd089e9b18d125bdcd5079363b09616 \
+ --hash=sha256:70bdfacdc183dac838b2a0aaff2dd6134a457c52fe68a9c6bbab435483d2b9df \
+ --hash=sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf \
+ --hash=sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0 \
+ --hash=sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a \
+ --hash=sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f \
+ --hash=sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22 \
+ --hash=sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356 \
+ --hash=sha256:89f3738167bab8c1084b94e23023d41d247117ac149fa0fbcb5bd4cf6262b353 \
+ --hash=sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e \
+ --hash=sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7 \
+ --hash=sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5 \
+ --hash=sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8 \
+ --hash=sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde \
+ --hash=sha256:a2d614cb2372c7101a12ea8b96dd56f81c986d247c5a73db67063f3ed1ca4a52 \
+ --hash=sha256:ac5bf81d79d2c8eeb2ef6359b2e1687a1e9ebf46c2b1f970da9a9255df51d190 \
+ --hash=sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05 \
+ --hash=sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937 \
+ --hash=sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867 \
+ --hash=sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d \
+ --hash=sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf \
+ --hash=sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f \
+ --hash=sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd \
+ --hash=sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da \
+ --hash=sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071 \
+ --hash=sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88 \
+ --hash=sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17 \
+ --hash=sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c \
+ --hash=sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66 \
+ --hash=sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb \
+ --hash=sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c \
+ --hash=sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25 \
+ --hash=sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0 \
+ --hash=sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927 \
+ --hash=sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6 \
+ --hash=sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c \
+ --hash=sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59 \
+ --hash=sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb \
+ --hash=sha256:e9a5e3406e3ed8125ae1a3b37c12f3434e2b1f0fa053197c5557895b4fb09606 \
+ --hash=sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef \
+ --hash=sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3 \
+ --hash=sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da \
+ --hash=sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132 \
+ --hash=sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7 \
+ --hash=sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f \
+ --hash=sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2 \
+ --hash=sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f \
+ --hash=sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667
# via
# ocotilloapi
# sqlalchemy
@@ -1088,9 +1142,9 @@ pg8000==1.31.5 \
--hash=sha256:0af2c1926b153307639868d2ee5cef6cd3a7d07448e12736989b10e1d491e201 \
--hash=sha256:46ebb03be52b7a77c03c725c79da2ca281d6e8f59577ca66b17c9009618cae78
# via ocotilloapi
-phonenumbers==9.0.34 \
- --hash=sha256:00751c75d1166485ca80ce02ec15b6a61a2628e9b313381579330bc70c934075 \
- --hash=sha256:1221bf8e65bd2c02770226488af806d4636814bc997104d3a1f7de6ed6410bd2
+phonenumbers==9.0.35 \
+ --hash=sha256:57ef9787ddf2bc8cc0906d5c876d43fcd65fa25e7330d00b9f2ba8528870b72a \
+ --hash=sha256:b19d97e8c448ccfd8d646888d2a65635372f75b9916006cc0a2f809b531baaea
# via ocotilloapi
pillow==12.3.0 \
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
@@ -1260,9 +1314,9 @@ propcache==0.5.2 \
# aiohttp
# ocotilloapi
# yarl
-proto-plus==1.28.1 \
- --hash=sha256:6660f5f1970874bdcfc3088b435188a36a37bd3596668f7d726417c4ae8cfbed \
- --hash=sha256:832e68e7fe064cf90ab153b6e5eb935b27891bb89aaeb68b115e9b702f6cb168
+proto-plus==1.28.2 \
+ --hash=sha256:26d843eb99c1e32fdf1d20ff0faae56607f7748fe774acf9ecd5cfe6c6472501 \
+ --hash=sha256:b874236fcac2358f601e4330bcb76cb8b89c851303ccf4078408b3d4774d1c52
# via
# google-api-core
# ocotilloapi
@@ -1516,9 +1570,9 @@ python-multipart==0.0.32 \
# via
# ocotilloapi
# starlette-admin
-pytz==2026.2 \
- --hash=sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126 \
- --hash=sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a
+pytz==2026.3.post1 \
+ --hash=sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d \
+ --hash=sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815
# via
# dateparser
# ocotilloapi
@@ -1830,15 +1884,15 @@ rsa==4.9.1 \
# via
# ocotilloapi
# python-jose
-scramp==1.4.12 \
- --hash=sha256:6adb2828c5d64bd7785a6878eed30f66ce0fae60bf5fc07c26ce1f521db3ec3e \
- --hash=sha256:94b38decf26005b835050d06541a5aefb9914497f4de789c6d82a0abc4b934de
+scramp==1.4.15 \
+ --hash=sha256:9d6102948d9005e3802384a328429dfd67d691a65791007c354ff89895857396 \
+ --hash=sha256:d25cdd3dbc493773647bccb93e4e85bbff0c091141ec8ff8d61bad1e3638082d
# via
# ocotilloapi
# pg8000
-sentry-sdk==2.66.0 \
- --hash=sha256:096136c214c602be2b323524d30755dc5b30ec5a218a206207f33b12c05c6f11 \
- --hash=sha256:9727d35aa83c56cd53294676fe65b96296a334c9ce107fa2142bd70f47acb265
+sentry-sdk==2.66.1 \
+ --hash=sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6 \
+ --hash=sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc
# via ocotilloapi
shapely==2.1.2 \
--hash=sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9 \
diff --git a/uv.lock b/uv.lock
index 3bc143c1f..81c778f97 100644
--- a/uv.lock
+++ b/uv.lock
@@ -35,7 +35,7 @@ wheels = [
[[package]]
name = "aiohttp"
-version = "3.14.1"
+version = "3.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -46,72 +46,72 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
- { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
- { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
- { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
- { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
- { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
- { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
- { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
- { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
- { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
- { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
- { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
- { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
- { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
- { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
- { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
- { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
- { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
- { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
- { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
- { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
- { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
- { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
- { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
- { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
- { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
- { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
- { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
- { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
- { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
- { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
- { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
- { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
- { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
- { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
- { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
- { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
- { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
- { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
- { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
- { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
- { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
- { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
- { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
- { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
- { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
- { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
- { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
- { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
- { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
- { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
- { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
- { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
- { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
- { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
- { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
- { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
- { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
- { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
- { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
- { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
- { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
- { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
- { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
+ { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
+ { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
+ { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
+ { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
+ { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
+ { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
+ { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
+ { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
+ { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
+ { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
+ { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
+ { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
+ { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
+ { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
+ { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
+ { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
+ { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
+ { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
+ { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
+ { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
+ { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
+ { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
]
[[package]]
@@ -160,11 +160,11 @@ wheels = [
[[package]]
name = "annotated-types"
-version = "0.7.0"
+version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
@@ -395,20 +395,20 @@ wheels = [
[[package]]
name = "cachetools"
-version = "7.1.4"
+version = "7.1.6"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/55/af/861ebc2e318a5c3300e3eb63bc4d30f3d70a46d13b360093728ac0705eed/cachetools-7.1.6.tar.gz", hash = "sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1", size = 40572, upload-time = "2026-07-23T22:47:53.737Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/f2/2086ba18a925a73586c4d4e61d25f4a6058e56fd00d77ce8f1d361ab4c9b/cachetools-7.1.6-py3-none-any.whl", hash = "sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096", size = 16954, upload-time = "2026-07-23T22:47:52.397Z" },
]
[[package]]
name = "certifi"
-version = "2026.6.17"
+version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
@@ -567,19 +567,21 @@ wheels = [
[[package]]
name = "cloud-sql-python-connector"
-version = "1.20.4"
+version = "1.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "aiofiles" },
{ name = "aiohttp" },
{ name = "cryptography" },
{ name = "dnspython" },
{ name = "google-auth" },
+ { name = "googleapis-common-protos" },
+ { name = "grpcio" },
+ { name = "protobuf" },
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fb/f6/cd4b630fca8f165db508795bc354f14e7155444a25a204b3da356134c3e8/cloud_sql_python_connector-1.20.4.tar.gz", hash = "sha256:fe2dbee747543ad2c720760c53064f0ef42ed04218981e1f7231362a88b3cf44", size = 44205, upload-time = "2026-06-26T23:04:12.62Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ea/a0/e1554a92336ac1df06c51553ba6cf4ec868788a81723130318713441a245/cloud_sql_python_connector-1.21.0.tar.gz", hash = "sha256:a5295627caa588c5c4b7b718d1954b8cf43de1dba9749b60b756984a1ea9cb21", size = 45183, upload-time = "2026-07-24T01:51:15.378Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0b/38/10a95226732a3d81ebcd157e5cb750b9d52a534e017dd177cc2321aea895/cloud_sql_python_connector-1.20.4-py3-none-any.whl", hash = "sha256:4c1cd8b573d5e9b93a6f390ccf772fa431afdcc32025b1577e2bafa89756a9f6", size = 50099, upload-time = "2026-06-26T23:04:11.098Z" },
+ { url = "https://files.pythonhosted.org/packages/62/25/99042398d3bf14a03bb59f08f872a49d3cc7b9e34c35b05a3acf0ef350ae/cloud_sql_python_connector-1.21.0-py3-none-any.whl", hash = "sha256:104e47d1a06448ec1231cf76454cb474f8c1954f970c7c2931b1aa2088805d8d", size = 51190, upload-time = "2026-07-24T01:51:13.976Z" },
]
[[package]]
@@ -807,7 +809,7 @@ wheels = [
[[package]]
name = "fastapi"
-version = "0.139.2"
+version = "0.140.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -816,9 +818,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/fa/67d7232a733c5f5cea8859dcd6f6de78a688bb8886676808e3f5a8358008/fastapi-0.140.2.tar.gz", hash = "sha256:5f64faeb12339d783510db0498da660539195ac7b5bdf69c0fc558f4580a9724", size = 421280, upload-time = "2026-07-27T14:15:39.682Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" },
+ { url = "https://files.pythonhosted.org/packages/74/ed/15196be41f2bf84e358d899e62daf5666ba4f6dbab4cbad87d16cc16df6d/fastapi-0.140.2-py3-none-any.whl", hash = "sha256:944336ef298148dfd97478638567b223d929aba8717ad8be73ae962a62ecd61d", size = 130883, upload-time = "2026-07-27T14:15:38.099Z" },
]
[[package]]
@@ -963,7 +965,7 @@ wheels = [
[[package]]
name = "google-api-core"
-version = "2.32.0"
+version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-auth" },
@@ -972,14 +974,14 @@ dependencies = [
{ name = "protobuf" },
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/03/33/00277be1305fd68355d08197f05e22db259c0cff49a10c8590a1869ade9b/google_api_core-2.32.0.tar.gz", hash = "sha256:2b33aad226b19272458c46abfe5c5a38d9531ece0c44502129a1463ce83674ac", size = 177659, upload-time = "2026-07-16T20:36:07.717Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/87/62/8fb1fb647d2788c950d69d6a769cd9d55c918ac1fc57be2f90b7e4029787/google_api_core-2.33.0.tar.gz", hash = "sha256:3a36bcc3e319783f4c97da41f6f45ea6ffcaa55848e341de16e09cb70243c2bb", size = 181607, upload-time = "2026-07-22T16:28:28.027Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/81/44/5018c5ac1526c98169db98d87a6ff7d5508f5246621c3ee1a046fdd5e0a6/google_api_core-2.32.0-py3-none-any.whl", hash = "sha256:ae1f0d58a6c8869350bf469f8eb3092e7f8c494a942d9525494afb6c162b0904", size = 174198, upload-time = "2026-07-16T20:35:41.865Z" },
+ { url = "https://files.pythonhosted.org/packages/89/31/5056a347bb934ea04583c8b27916ef1501729c72638629545bce26ff4223/google_api_core-2.33.0-py3-none-any.whl", hash = "sha256:a2e22a0c1d0f03eafff1858b38cf46f832d5902b0c052235bf0ab8402929fbdc", size = 176462, upload-time = "2026-07-22T16:28:22.447Z" },
]
[[package]]
name = "google-api-python-client"
-version = "2.184.0"
+version = "2.198.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
@@ -988,22 +990,22 @@ dependencies = [
{ name = "httplib2" },
{ name = "uritemplate" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7c/30/8b3a626ccf84ca43da62d77e2d40d70bedc6387951cc5104011cddce34e0/google_api_python_client-2.184.0.tar.gz", hash = "sha256:ef2a3330ad058cdfc8a558d199c051c3356f6ed012436c3ad3d08b67891b039f", size = 13694120, upload-time = "2025-10-01T21:13:48.961Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/53/0cd38e3a29d72ce45e27feba2ce1cd8049d69af9c48cb14fb164f1be9133/google_api_python_client-2.198.0.tar.gz", hash = "sha256:dfe3e16fb241af6e9c460a33f65085b3450e05cea09364f6b5d8997fb7e43e2a", size = 15060142, upload-time = "2026-06-25T14:32:42.953Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/38/d25ae1565103a545cf18207a5dec09a6d39ad88e5b0399a2430e9edb0550/google_api_python_client-2.184.0-py3-none-any.whl", hash = "sha256:15a18d02f42de99416921c77be235d12ead474e474a1abc348b01a2b92633fa4", size = 14260480, upload-time = "2025-10-01T21:13:46.037Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/92/0fc9e7a09eb240c31b879bd8d2e43f81ed1f86c4798b79ead4a083921ab3/google_api_python_client-2.198.0-py3-none-any.whl", hash = "sha256:fabac935474e817da5e662ff61bf7139439d6f92b32d332a7318a2d45931e03e", size = 15644203, upload-time = "2026-06-25T14:32:39.963Z" },
]
[[package]]
name = "google-auth"
-version = "2.56.0"
+version = "2.56.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyasn1-modules" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/58/66/b4ba60005743e01933e22b4f62313e063f7460458b7d8a358427b4930013/google_auth-2.56.0.tar.gz", hash = "sha256:f90fa030b569a92654b9d690665a073841df33d57487be53db583a9a0867a553", size = 364629, upload-time = "2026-07-13T19:09:57.143Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/33/dbc946a407401b975f0719658f18e664ece2109f79ffd1ff3bf226c205f4/google_auth-2.56.2.tar.gz", hash = "sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051", size = 365820, upload-time = "2026-07-21T21:53:28.188Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a8/7d/cd3e187f14ce832e419e70709bfcc40cb0dc11517d5d03c9d3919bcc3101/google_auth-2.56.0-py3-none-any.whl", hash = "sha256:6e88c10217e07a92bfd01cac8ee99e32ccfb08414c3102e6c5b8d58f37a0d1e0", size = 257976, upload-time = "2026-07-13T19:09:42.685Z" },
+ { url = "https://files.pythonhosted.org/packages/88/63/50636aae68c9bf17c891c7eb18b49baa9bd6b31d2a97b8de4813a9fc8d1c/google_auth-2.56.2-py3-none-any.whl", hash = "sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6", size = 258588, upload-time = "2026-07-21T21:53:26.399Z" },
]
[[package]]
@@ -1093,59 +1095,90 @@ wheels = [
[[package]]
name = "greenlet"
-version = "3.5.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" },
- { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" },
- { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" },
- { url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" },
- { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" },
- { url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" },
- { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" },
- { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" },
- { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" },
- { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" },
- { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
- { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
- { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
- { url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" },
- { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
- { url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" },
- { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
- { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
- { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
- { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" },
- { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
- { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
- { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
- { url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" },
- { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
- { url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" },
- { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
- { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
- { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
- { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" },
- { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" },
- { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" },
- { url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" },
- { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" },
- { url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" },
- { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" },
- { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" },
- { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" },
- { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" },
- { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" },
- { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" },
- { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" },
- { url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" },
- { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" },
- { url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" },
- { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" },
- { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" },
- { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" },
- { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" },
+version = "3.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" },
+ { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" },
+ { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" },
+ { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" },
+ { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" },
+ { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" },
+ { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" },
+ { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" },
+ { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" },
+ { url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" },
+ { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" },
+ { url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" },
+ { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" },
+ { url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" },
+ { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" },
+]
+
+[[package]]
+name = "grpcio"
+version = "1.83.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" },
+ { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" },
+ { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" },
+ { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" },
+ { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" },
+ { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" },
+ { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" },
+ { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" },
]
[[package]]
@@ -1676,11 +1709,11 @@ dev = [
requires-dist = [
{ name = "aiofiles", specifier = "==24.1.0" },
{ name = "aiohappyeyeballs", specifier = "==2.7.1" },
- { name = "aiohttp", specifier = "==3.14.1" },
+ { name = "aiohttp", specifier = "==3.14.3" },
{ name = "aiosignal", specifier = "==1.4.0" },
{ name = "aiosqlite", specifier = "==0.22.1" },
{ name = "alembic", specifier = "==1.18.5" },
- { name = "annotated-types", specifier = "==0.7.0" },
+ { name = "annotated-types", specifier = "==0.8.0" },
{ name = "anyio", specifier = "==4.14.2" },
{ name = "apitally", extras = ["fastapi"], specifier = "==0.25.1" },
{ name = "asgiref", specifier = "==3.12.1" },
@@ -1689,28 +1722,28 @@ requires-dist = [
{ name = "attrs", specifier = "==26.1.0" },
{ name = "authlib", specifier = "==1.7.2" },
{ name = "bcrypt", specifier = "==4.3.0" },
- { name = "cachetools", specifier = "==7.1.4" },
- { name = "certifi", specifier = "==2026.6.17" },
+ { name = "cachetools", specifier = "==7.1.6" },
+ { name = "certifi", specifier = "==2026.7.22" },
{ name = "cffi", specifier = "==2.1.0" },
{ name = "charset-normalizer", specifier = "==3.4.9" },
{ name = "click", specifier = "==8.4.2" },
- { name = "cloud-sql-python-connector", specifier = "==1.20.4" },
+ { name = "cloud-sql-python-connector", specifier = "==1.21.0" },
{ name = "cryptography", specifier = "==48.0.1" },
{ name = "dnspython", specifier = "==2.8.0" },
{ name = "dotenv", specifier = "==0.9.9" },
{ name = "email-validator", specifier = "==2.3.0" },
- { name = "fastapi", specifier = "==0.139.2" },
+ { name = "fastapi", specifier = "==0.140.2" },
{ name = "fastapi-pagination", specifier = "==0.15.15" },
{ name = "frozenlist", specifier = "==1.8.0" },
{ name = "geoalchemy2", specifier = "==0.20.0" },
- { name = "google-api-core", specifier = "==2.32.0" },
- { name = "google-auth", specifier = "==2.56.0" },
+ { name = "google-api-core", specifier = "==2.33.0" },
+ { name = "google-auth", specifier = "==2.56.2" },
{ name = "google-cloud-core", specifier = "==2.6.0" },
{ name = "google-cloud-storage", specifier = "==3.13.0" },
{ name = "google-crc32c", specifier = "==1.8.0" },
{ name = "google-resumable-media", specifier = "==2.10.0" },
{ name = "googleapis-common-protos", specifier = "==1.75.0" },
- { name = "greenlet", specifier = "==3.5.3" },
+ { name = "greenlet", specifier = "==3.5.4" },
{ name = "gunicorn", specifier = "==23.0.0" },
{ name = "h11", specifier = "==0.16.0" },
{ name = "httpcore", specifier = "==1.0.9" },
@@ -1727,11 +1760,11 @@ requires-dist = [
{ name = "pandas", specifier = "==2.3.2" },
{ name = "pandas-stubs", specifier = "~=2.3.2" },
{ name = "pg8000", specifier = "==1.31.5" },
- { name = "phonenumbers", specifier = "==9.0.34" },
+ { name = "phonenumbers", specifier = "==9.0.35" },
{ name = "pillow", specifier = "==12.3.0" },
{ name = "pluggy", specifier = "==1.6.0" },
{ name = "propcache", specifier = "==0.5.2" },
- { name = "proto-plus", specifier = "==1.28.1" },
+ { name = "proto-plus", specifier = "==1.28.2" },
{ name = "protobuf", specifier = "==6.33.5" },
{ name = "psycopg2-binary", specifier = ">=2.9.12" },
{ name = "pyasn1", specifier = "==0.6.4" },
@@ -1748,11 +1781,11 @@ requires-dist = [
{ name = "python-dateutil", specifier = "==2.9.0.post0" },
{ name = "python-jose", specifier = ">=3.5.0" },
{ name = "python-multipart", specifier = "==0.0.32" },
- { name = "pytz", specifier = "==2026.2" },
+ { name = "pytz", specifier = "==2026.3.post1" },
{ name = "requests", specifier = "==2.34.2" },
{ name = "rsa", specifier = "==4.9.1" },
- { name = "scramp", specifier = "==1.4.12" },
- { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.66.0" },
+ { name = "scramp", specifier = "==1.4.15" },
+ { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.66.1" },
{ name = "shapely", specifier = "==2.1.2" },
{ name = "six", specifier = "==1.17.0" },
{ name = "sniffio", specifier = "==1.3.1" },
@@ -1775,7 +1808,7 @@ requires-dist = [
[package.metadata.requires-dev]
cli = [
- { name = "google-api-python-client", specifier = "==2.184.0" },
+ { name = "google-api-python-client", specifier = "==2.198.0" },
{ name = "openpyxl", specifier = "==3.1.5" },
]
dev = [
@@ -1783,7 +1816,7 @@ dev = [
{ name = "black", specifier = ">=26.5.1" },
{ name = "faker", specifier = ">=25.0.0" },
{ name = "flake8", specifier = ">=7.3.0" },
- { name = "pre-commit", specifier = ">=4.6.0" },
+ { name = "pre-commit", specifier = ">=4.6.1" },
{ name = "pyhamcrest", specifier = ">=2.0.3" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-cov", specifier = ">=6.2.1" },
@@ -1938,11 +1971,11 @@ wheels = [
[[package]]
name = "phonenumbers"
-version = "9.0.34"
+version = "9.0.35"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/86/c3/e154829a50679c38ae28ec9c4f151f2c425db5e70fd445266e76f6d6cd65/phonenumbers-9.0.34.tar.gz", hash = "sha256:00751c75d1166485ca80ce02ec15b6a61a2628e9b313381579330bc70c934075", size = 2306776, upload-time = "2026-07-03T06:30:37.358Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/9c/af506fdd7220cdf6fcea27a3b8d3dc5feb239f5072137c7900155c0c6cf9/phonenumbers-9.0.35.tar.gz", hash = "sha256:b19d97e8c448ccfd8d646888d2a65635372f75b9916006cc0a2f809b531baaea", size = 2306830, upload-time = "2026-07-26T08:28:17.303Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/16/0a/3a7980f3b071dde9a297d85cf6b18ba4bc2e5024e1c453b3da8a1dc14268/phonenumbers-9.0.34-py2.py3-none-any.whl", hash = "sha256:1221bf8e65bd2c02770226488af806d4636814bc997104d3a1f7de6ed6410bd2", size = 2595344, upload-time = "2026-07-03T06:30:33.913Z" },
+ { url = "https://files.pythonhosted.org/packages/62/35/80f35904033e5584966c255a0140f48934d18c4711c04d4bcbdf5b330bfb/phonenumbers-9.0.35-py2.py3-none-any.whl", hash = "sha256:57ef9787ddf2bc8cc0906d5c876d43fcd65fa25e7330d00b9f2ba8528870b72a", size = 2595440, upload-time = "2026-07-26T08:28:14.033Z" },
]
[[package]]
@@ -2027,7 +2060,7 @@ wheels = [
[[package]]
name = "pre-commit"
-version = "4.6.0"
+version = "4.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
@@ -2036,9 +2069,9 @@ dependencies = [
{ name = "pyyaml" },
{ name = "virtualenv" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/3a/ddb78f32a0814e66b18a099377a106a2dcdce92d86a034d69d65df9b256e/pre_commit-4.6.1.tar.gz", hash = "sha256:03e809865c7d178b9979d06c761fcbfe6808fdaded8581a745bb110e52050421", size = 198646, upload-time = "2026-07-21T20:56:58.225Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl", hash = "sha256:0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717", size = 226186, upload-time = "2026-07-21T20:56:57.064Z" },
]
[[package]]
@@ -2120,14 +2153,14 @@ wheels = [
[[package]]
name = "proto-plus"
-version = "1.28.1"
+version = "1.28.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/87/44/767757fd2cdd4a60d7e4440d9f7b491d6131103d313638d2c03e06c268fb/proto_plus-1.28.1.tar.gz", hash = "sha256:832e68e7fe064cf90ab153b6e5eb935b27891bb89aaeb68b115e9b702f6cb168", size = 57166, upload-time = "2026-07-08T17:04:02.367Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/3e/29e0d6a2c5adde6ab5772253fd16ab346324026b89a66e354689c86d0584/proto_plus-1.28.2.tar.gz", hash = "sha256:26d843eb99c1e32fdf1d20ff0faae56607f7748fe774acf9ecd5cfe6c6472501", size = 58063, upload-time = "2026-07-22T16:28:29.119Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6a/34/2f2b57dbfd145b995a29847a16b0903fce5ef6ad3c7aad740a609c5d3678/proto_plus-1.28.1-py3-none-any.whl", hash = "sha256:6660f5f1970874bdcfc3088b435188a36a37bd3596668f7d726417c4ae8cfbed", size = 50408, upload-time = "2026-07-08T17:03:34.532Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/84/4e9a53a062d4073c74897a6bd20fff74d55307341b3e85c081002462b3ef/proto_plus-1.28.2-py3-none-any.whl", hash = "sha256:b874236fcac2358f601e4330bcb76cb8b89c851303ccf4078408b3d4774d1c52", size = 50693, upload-time = "2026-07-22T16:28:24.059Z" },
]
[[package]]
@@ -2589,11 +2622,11 @@ wheels = [
[[package]]
name = "pytz"
-version = "2026.2"
+version = "2026.3.post1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" },
]
[[package]]
@@ -2847,27 +2880,27 @@ wheels = [
[[package]]
name = "scramp"
-version = "1.4.12"
+version = "1.4.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asn1crypto" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1a/0c/a7a7f217b83cb7439abe2189e0a65b648a81f1b72d6cd2a80161b74c1bae/scramp-1.4.12.tar.gz", hash = "sha256:94b38decf26005b835050d06541a5aefb9914497f4de789c6d82a0abc4b934de", size = 19179, upload-time = "2026-07-05T10:30:54.791Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/6dd9b1e7ff7973eeb6550d0d609c3bdca8f27de2719a837628c9fd44c087/scramp-1.4.15.tar.gz", hash = "sha256:d25cdd3dbc493773647bccb93e4e85bbff0c091141ec8ff8d61bad1e3638082d", size = 20958, upload-time = "2026-07-26T17:49:06.655Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/9c/23bbbe3202c61e5b03708967014c51ea69972ddfbcd73fa2c5c0211f16a7/scramp-1.4.12-py3-none-any.whl", hash = "sha256:6adb2828c5d64bd7785a6878eed30f66ce0fae60bf5fc07c26ce1f521db3ec3e", size = 14361, upload-time = "2026-07-05T10:30:53.177Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/a4/6a6e67a8bdbc17b89537de2886cef62536d47c25b1f1d85ecfa81a4915f1/scramp-1.4.15-py3-none-any.whl", hash = "sha256:9d6102948d9005e3802384a328429dfd67d691a65791007c354ff89895857396", size = 15891, upload-time = "2026-07-26T17:49:04.913Z" },
]
[[package]]
name = "sentry-sdk"
-version = "2.66.0"
+version = "2.66.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/48/ff/670abe04c5072719b5060ed93851d0d69525d60f8f2c5810f8becd58f9c1/sentry_sdk-2.66.0.tar.gz", hash = "sha256:9727d35aa83c56cd53294676fe65b96296a334c9ce107fa2142bd70f47acb265", size = 935745, upload-time = "2026-07-16T12:42:04.663Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7f/6f/d59cad0889d15fde85254cf58e701484de3f3f0406003b3197746910b19b/sentry_sdk-2.66.1.tar.gz", hash = "sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc", size = 940543, upload-time = "2026-07-22T12:26:54.553Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/bb/49b10783f29067da2eec179320617e94faf63196609de47aeab3c26c3325/sentry_sdk-2.66.0-py3-none-any.whl", hash = "sha256:096136c214c602be2b323524d30755dc5b30ec5a218a206207f33b12c05c6f11", size = 504769, upload-time = "2026-07-16T12:42:02.919Z" },
+ { url = "https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl", hash = "sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6", size = 505555, upload-time = "2026-07-22T12:26:52.71Z" },
]
[package.optional-dependencies]
From a316d0ccd22d3be14f12ebf39b20a3a3b819ad08 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 28 Jul 2026 21:22:01 -0700
Subject: [PATCH 018/151] chore: remove unused admin UI
Remove the starlette-admin web interface and all supporting
infrastructure. The admin UI was never used in practice.
- Delete the admin/ package (views, auth, config, fields)
- Remove lazy admin initialization and session middleware from
core/initializers.py and core/factory.py (sessions were only
used by admin auth)
- Drop starlette-admin and itsdangerous dependencies
- Remove SESSION_SECRET_KEY plumbing from docker-compose,
App Engine template, CI/CD workflows, .env.example, and tests
- Delete admin-specific unit, integration, and behave tests
- Update README
Co-Authored-By: Claude Fable 5
---
.env.example | 2 -
.github/app.template.yaml | 2 -
.github/workflows/CD_production.yml | 1 -
.github/workflows/CD_staging.yml | 1 -
.github/workflows/CD_testing.yml | 1 -
.github/workflows/tests.yml | 2 -
README.md | 6 +-
admin/__init__.py | 24 --
admin/auth.py | 298 ------------------
admin/auth_routes.py | 78 -----
admin/config.py | 218 -------------
admin/fields.py | 141 ---------
admin/views/__init__.py | 97 ------
admin/views/aquifer_system.py | 94 ------
admin/views/aquifer_type.py | 86 -----
admin/views/asset.py | 100 ------
admin/views/associated_data.py | 113 -------
admin/views/base.py | 142 ---------
admin/views/chemistry_sampleinfo.py | 175 ----------
admin/views/contact.py | 129 --------
admin/views/data_provenance.py | 94 ------
admin/views/deployment.py | 138 --------
admin/views/field.py | 199 ------------
admin/views/field_parameters.py | 139 --------
admin/views/geologic_formation.py | 85 -----
admin/views/group.py | 93 ------
admin/views/hydraulicsdata.py | 149 ---------
admin/views/lexicon.py | 97 ------
admin/views/location.py | 122 -------
admin/views/major_chemistry.py | 169 ----------
admin/views/minor_trace_chemistry.py | 138 --------
admin/views/notes.py | 91 ------
admin/views/observation.py | 128 --------
admin/views/parameter.py | 90 ------
admin/views/radionuclides.py | 165 ----------
admin/views/sample.py | 103 ------
admin/views/sensor.py | 123 --------
admin/views/soil_rock_results.py | 77 -----
admin/views/stratigraphy.py | 100 ------
admin/views/surface_water.py | 96 ------
admin/views/surface_water_photos.py | 71 -----
admin/views/thing.py | 161 ----------
admin/views/transducer_observation.py | 205 ------------
.../waterlevelscontinuous_pressure_daily.py | 148 ---------
admin/views/weather_data.py | 66 ----
admin/views/weather_photos.py | 70 ----
core/factory.py | 5 -
core/initializers.py | 48 ---
docker-compose.yml | 1 -
pyproject.toml | 8 -
tests/__init__.py | 2 -
tests/conftest.py | 2 -
.../admin-minor-trace-chemistry.feature | 45 ---
.../steps/admin-minor-trace-chemistry.py | 143 ---------
.../test_admin_minor_trace_chemistry.py | 237 --------------
tests/test_admin_minor_trace_chemistry.py | 217 -------------
tests/test_admin_views.py | 110 -------
tests/test_lazy_admin.py | 34 --
uv.lock | 23 --
59 files changed, 2 insertions(+), 5700 deletions(-)
delete mode 100644 admin/__init__.py
delete mode 100644 admin/auth.py
delete mode 100644 admin/auth_routes.py
delete mode 100644 admin/config.py
delete mode 100644 admin/fields.py
delete mode 100644 admin/views/__init__.py
delete mode 100644 admin/views/aquifer_system.py
delete mode 100644 admin/views/aquifer_type.py
delete mode 100644 admin/views/asset.py
delete mode 100644 admin/views/associated_data.py
delete mode 100644 admin/views/base.py
delete mode 100644 admin/views/chemistry_sampleinfo.py
delete mode 100644 admin/views/contact.py
delete mode 100644 admin/views/data_provenance.py
delete mode 100644 admin/views/deployment.py
delete mode 100644 admin/views/field.py
delete mode 100644 admin/views/field_parameters.py
delete mode 100644 admin/views/geologic_formation.py
delete mode 100644 admin/views/group.py
delete mode 100644 admin/views/hydraulicsdata.py
delete mode 100644 admin/views/lexicon.py
delete mode 100644 admin/views/location.py
delete mode 100644 admin/views/major_chemistry.py
delete mode 100644 admin/views/minor_trace_chemistry.py
delete mode 100644 admin/views/notes.py
delete mode 100644 admin/views/observation.py
delete mode 100644 admin/views/parameter.py
delete mode 100644 admin/views/radionuclides.py
delete mode 100644 admin/views/sample.py
delete mode 100644 admin/views/sensor.py
delete mode 100644 admin/views/soil_rock_results.py
delete mode 100644 admin/views/stratigraphy.py
delete mode 100644 admin/views/surface_water.py
delete mode 100644 admin/views/surface_water_photos.py
delete mode 100644 admin/views/thing.py
delete mode 100644 admin/views/transducer_observation.py
delete mode 100644 admin/views/waterlevelscontinuous_pressure_daily.py
delete mode 100644 admin/views/weather_data.py
delete mode 100644 admin/views/weather_photos.py
delete mode 100644 tests/features/admin-minor-trace-chemistry.feature
delete mode 100644 tests/features/steps/admin-minor-trace-chemistry.py
delete mode 100644 tests/integration/test_admin_minor_trace_chemistry.py
delete mode 100644 tests/test_admin_minor_trace_chemistry.py
delete mode 100644 tests/test_admin_views.py
delete mode 100644 tests/test_lazy_admin.py
diff --git a/.env.example b/.env.example
index dfdc98844..bfa04542d 100644
--- a/.env.example
+++ b/.env.example
@@ -84,8 +84,6 @@ AUTHENTIK_CLIENT_ID=
AUTHENTIK_AUTHORIZE_URL=
AUTHENTIK_TOKEN_URL=
-# middleware
-SESSION_SECRET_KEY=your_secret_key_here
# feedback endpoint (POST /feedback) — bug reports and feature requests
JIRA_BASE_URL=https://nmbgmr.atlassian.net
diff --git a/.github/app.template.yaml b/.github/app.template.yaml
index 6a1a52fb4..bb44e584c 100644
--- a/.github/app.template.yaml
+++ b/.github/app.template.yaml
@@ -42,8 +42,6 @@ env_variables:
AUTHENTIK_CLIENT_ID: "${AUTHENTIK_CLIENT_ID}"
AUTHENTIK_AUTHORIZE_URL: "${AUTHENTIK_AUTHORIZE_URL}"
AUTHENTIK_TOKEN_URL: "${AUTHENTIK_TOKEN_URL}"
- SESSION_SECRET_KEY: |-
- ${SESSION_SECRET_KEY}
APITALLY_CLIENT_ID: "${APITALLY_CLIENT_ID}"
JIRA_BASE_URL: "${JIRA_BASE_URL}"
JIRA_EMAIL: "${JIRA_EMAIL}"
diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml
index 21c6acc07..0ac251141 100644
--- a/.github/workflows/CD_production.yml
+++ b/.github/workflows/CD_production.yml
@@ -126,7 +126,6 @@ jobs:
AUTHENTIK_CLIENT_ID: "${{ vars.AUTHENTIK_CLIENT_ID }}"
AUTHENTIK_AUTHORIZE_URL: "${{ vars.AUTHENTIK_AUTHORIZE_URL }}"
AUTHENTIK_TOKEN_URL: "${{ vars.AUTHENTIK_TOKEN_URL }}"
- SESSION_SECRET_KEY: "${{ secrets.SESSION_SECRET_KEY }}"
APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}"
JIRA_BASE_URL: "${{ vars.JIRA_BASE_URL || 'https://nmbgmr.atlassian.net' }}"
JIRA_EMAIL: "${{ steps.feedback-secrets.outputs.jira_email }}"
diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml
index 348f2b4b8..cc54ef0f3 100644
--- a/.github/workflows/CD_staging.yml
+++ b/.github/workflows/CD_staging.yml
@@ -86,7 +86,6 @@ jobs:
AUTHENTIK_CLIENT_ID: "${{ vars.AUTHENTIK_CLIENT_ID }}"
AUTHENTIK_AUTHORIZE_URL: "${{ vars.AUTHENTIK_AUTHORIZE_URL }}"
AUTHENTIK_TOKEN_URL: "${{ vars.AUTHENTIK_TOKEN_URL }}"
- SESSION_SECRET_KEY: "${{ secrets.SESSION_SECRET_KEY }}"
APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}"
JIRA_BASE_URL: "${{ vars.JIRA_BASE_URL || 'https://nmbgmr.atlassian.net' }}"
JIRA_EMAIL: "${{ steps.feedback-secrets.outputs.jira_email }}"
diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml
index ba4d4e790..8cefcb586 100644
--- a/.github/workflows/CD_testing.yml
+++ b/.github/workflows/CD_testing.yml
@@ -86,7 +86,6 @@ jobs:
AUTHENTIK_CLIENT_ID: "${{ vars.AUTHENTIK_CLIENT_ID }}"
AUTHENTIK_AUTHORIZE_URL: "${{ vars.AUTHENTIK_AUTHORIZE_URL }}"
AUTHENTIK_TOKEN_URL: "${{ vars.AUTHENTIK_TOKEN_URL }}"
- SESSION_SECRET_KEY: "${{ secrets.SESSION_SECRET_KEY }}"
APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}"
JIRA_BASE_URL: "${{ vars.JIRA_BASE_URL || 'https://nmbgmr.atlassian.net' }}"
JIRA_EMAIL: "${{ steps.feedback-secrets.outputs.jira_email }}"
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 57883f562..2a2da8e3e 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -28,7 +28,6 @@ jobs:
PYGEOAPI_POSTGRES_DB: ocotilloapi_test
DB_DRIVER: postgres
BASE_URL: http://localhost:8000
- SESSION_SECRET_KEY: supersecretkeyforunittests
AUTHENTIK_DISABLE_AUTHENTICATION: 1
services:
@@ -119,7 +118,6 @@ jobs:
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
diff --git a/README.md b/README.md
index 90ca4bc99..7a1248d1c 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ supports research, field operations, and public data delivery for the Bureau of
## 🗺️ OGC API - Features
The API exposes OGC API - Features endpoints under `/ogcapi` using `pygeoapi`.
-In App Engine deployments, `/admin` and `/ogcapi` are served from the same
+In App Engine deployments, `/ogcapi` is served from the same
application as the primary API. The service is intended to scale to zero
outside business hours and be kept warm during the workday with Cloud Scheduler
hits to `/_ah/warmup`.
@@ -152,7 +152,6 @@ Minimum vars to set in `.env` for local development:
* `POSTGRES_HOST` (`localhost` for local psql/pytest against mapped Docker port)
* `POSTGRES_PORT` (`5432`)
* `MODE` (`development` recommended locally)
-* `SESSION_SECRET_KEY` (required if you want to use `/admin`)
Auth-related vars (required when auth is enabled, optional when `AUTHENTIK_DISABLE_AUTHENTICATION=1`):
* `AUTHENTIK_DISABLE_AUTHENTICATION`
@@ -206,7 +205,7 @@ Notes:
* Requires Docker Desktop.
* By default, spins up two containers:
* `db` for PostGIS/PostgreSQL
- * `app` for the primary API, admin UI, and OGC API on `http://localhost:8000`
+ * `app` for the primary API and OGC API on `http://localhost:8000`
* `db` initializes both application databases in the same Postgres service:
* `ocotilloapi_dev`
* `ocotilloapi_test`
@@ -216,7 +215,6 @@ Notes:
* test: `ocotilloapi_test` (created by init SQL in `docker/db/init/01-create-test-db.sql`)
* The database listens on port `5432` both inside the container and on your host. Ensure `POSTGRES_PORT=5432` and `POSTGRES_DB=ocotilloapi_dev` in your `.env` to run local commands against the Docker dev DB (e.g., `uv run pytest`, `uv run python -m transfers.transfer`).
* To restore a local or GCS-backed SQL dump into your local target DB, run `source .venv/bin/activate && python -m cli.cli restore-local-db path/to/dump.sql` or `source .venv/bin/activate && python -m cli.cli restore-local-db gs://ocotillo/sql-exports/latest.sql.gz`.
-* `SESSION_SECRET_KEY` only needs to be set in `.env` if you plan to use `/admin`; without it, the API and `/ogcapi` still boot, but `/admin` will be unavailable.
#### Staging Data
diff --git a/admin/__init__.py b/admin/__init__.py
deleted file mode 100644
index 2816d3891..000000000
--- a/admin/__init__.py
+++ /dev/null
@@ -1,24 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-Starlette Admin package for OcotilloAPI.
-
-Provides web-based administrative interface for managing database records.
-"""
-
-from admin.config import create_admin
-
-__all__ = ["create_admin"]
diff --git a/admin/auth.py b/admin/auth.py
deleted file mode 100644
index 903068ab7..000000000
--- a/admin/auth.py
+++ /dev/null
@@ -1,298 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-Admin authentication provider integrating with existing Authentik OIDC auth.
-
-This module provides a Starlette Admin AuthProvider that integrates with the
-existing Authentik-based authentication system used by the OcotilloAPI API.
-"""
-
-import base64
-import hashlib
-import os
-import secrets
-from core.permissions import _get_token_payload, verify_token
-from dataclasses import dataclass
-from starlette.requests import Request
-from starlette.responses import RedirectResponse
-from starlette_admin.auth import AdminUser, AuthProvider
-from starlette_admin.exceptions import LoginFailed
-from typing import List
-from typing import Optional
-from urllib.parse import urlencode
-
-
-@dataclass
-class AdminUserWithRoles(AdminUser):
- """Extended AdminUser with roles for RBAC."""
-
- roles: List[str] = None
-
- def __post_init__(self):
- if self.roles is None:
- self.roles = []
-
-
-class NMSampleLocationsAuthProvider(AuthProvider):
- """
- Custom auth provider that integrates with existing Authentik OIDC authentication.
-
- Reuses the existing authentication infrastructure from core.permissions module.
-
- For MS Access users: This replaces Access file-level security with user-level
- authentication. Each user logs in with their Authentik credentials and gets
- assigned roles (Admin, Editor, Viewer) which control what they can do in the
- admin interface.
- """
-
- async def is_authenticated(self, request: Request) -> bool:
- """
- Check if user is authenticated by verifying their JWT token.
-
- This method is called on every admin page request to determine if the
- user should be allowed access.
-
- Returns:
- bool: True if user has a valid JWT token, False otherwise
- """
- # Check if authentication is disabled (development mode only)
- if int(os.environ.get("AUTHENTIK_DISABLE_AUTHENTICATION", 0)):
- from core.settings import settings
-
- if settings.mode != "production":
- # Allow unauthenticated access in development mode
- request.state.user = AdminUserWithRoles(
- username="dev_user", roles=["admin"]
- )
- return True
-
- try:
- # Try to get token from Authorization header
- authorization = request.headers.get("Authorization")
- if not authorization:
- # Try to get token from session/cookie
- token = request.session.get("token")
- if not token:
- return False
- else:
- # Extract token from "Bearer " format
- token = (
- authorization.split(" ")[1]
- if " " in authorization
- else authorization
- )
-
- # Verify token using existing authentication system
- is_valid = verify_token(token, scope=None, permissions=None)
-
- if is_valid:
- # Store user in request state for later access
- request.state.user = self._create_admin_user_from_token(token)
-
- return is_valid
- except Exception:
- return False
-
- def _create_admin_user_from_token(self, token: str) -> Optional[AdminUser]:
- """
- Extract user information from JWT token and create AdminUser instance.
-
- Args:
- token: JWT access token from Authentik
-
- Returns:
- AdminUser instance with username and roles, or None if token invalid
- """
- try:
- # Decode JWT payload
- payload = _get_token_payload(token)
-
- # Extract user information from JWT claims
- username = (
- payload.get("preferred_username")
- or payload.get("email")
- or payload.get("sub")
- )
- email = payload.get("email")
- groups = payload.get("groups", [])
-
- # Map Authentik groups to admin roles
- roles = []
-
- # Standard roles
- if "Admin" in groups:
- roles.append("admin")
- if "Editor" in groups:
- roles.append("editor")
- if "Viewer" in groups:
- roles.append("viewer")
-
- # AMP-specific roles (for AMPAPI-related data)
- if "AMPAdmin" in groups:
- roles.append("amp_admin")
- if "AMPEditor" in groups:
- roles.append("amp_editor")
- if "AMPViewer" in groups:
- roles.append("amp_viewer")
-
- # Lexicon-specific roles
- if "LexiconAdmin" in groups:
- roles.append("lexicon_admin")
- if "LexiconEditor" in groups:
- roles.append("lexicon_editor")
-
- return AdminUserWithRoles(
- username=username,
- photo_url=None, # Could add user avatar URL from OIDC if available
- roles=roles,
- )
- except Exception:
- return None
-
- def get_admin_user(self, request: Request) -> Optional[AdminUser]:
- """
- Get the current admin user from the request.
-
- This method is called by Starlette Admin to get user information for
- display in the UI and permission checks.
-
- Returns:
- AdminUser instance with username and roles, or None if not authenticated
- """
- # Check if user is already stored in request state
- if hasattr(request.state, "user"):
- return request.state.user
-
- try:
- # Get token from request
- authorization = request.headers.get("Authorization")
- if not authorization:
- token = request.session.get("token")
- if not token:
- return None
- else:
- token = (
- authorization.split(" ")[1]
- if " " in authorization
- else authorization
- )
-
- # Create AdminUser from token
- admin_user = self._create_admin_user_from_token(token)
-
- # Store in request state for future calls
- if admin_user:
- request.state.user = admin_user
-
- return admin_user
- except Exception:
- return None
-
- async def login(self, *args, **kwargs) -> RedirectResponse:
- """
- Redirect to Authentik OIDC login page.
-
- Note: Starlette Admin will show a login form, but we ignore the username/password
- and redirect to Authentik OAuth flow instead.
-
- Args:
- request: Starlette request object (extracted from args/kwargs)
- *args/**kwargs: Ignored, kept for compatibility with different
- Starlette Admin login call signatures
-
- Returns:
- RedirectResponse to Authentik authorization endpoint
- """
- # Starlette Admin has changed the AuthProvider.login signature across versions.
- # Accept *args/**kwargs and extract the Request to stay compatible whether
- # it calls login(request, data, ...) or login(username, password, remember_me, request).
- request: Optional[Request] = kwargs.get("request")
- if request is None:
- for arg in args:
- if isinstance(arg, Request):
- request = arg
- break
-
- if request is None:
- raise LoginFailed("Unable to determine login request context.")
-
- authentik_authorize_url = os.environ.get("AUTHENTIK_AUTHORIZE_URL")
- authentik_client_id = os.environ.get("AUTHENTIK_CLIENT_ID")
- if not authentik_authorize_url or not authentik_client_id:
- raise LoginFailed(
- "Authentik authentication is not configured. Please set AUTHENTIK_AUTHORIZE_URL and AUTHENTIK_CLIENT_ID environment variables."
- )
-
- # Store original URL to redirect back after login
- original_url = str(request.url_for("admin:index"))
- request.session["auth_redirect"] = original_url
- redirect_uri = str(request.url_for("admin_auth_callback"))
-
- # PKCE for public clients
- code_verifier = secrets.token_urlsafe(64)
- digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
- code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
-
- state = secrets.token_urlsafe(32)
- request.session["auth_state"] = state
- request.session["auth_code_verifier"] = code_verifier
-
- params = {
- "response_type": "code",
- "client_id": authentik_client_id,
- "redirect_uri": redirect_uri,
- "scope": "openid profile email",
- "state": state,
- "code_challenge": code_challenge,
- "code_challenge_method": "S256",
- }
-
- authorize_url = f"{authentik_authorize_url}?{urlencode(params)}"
- return RedirectResponse(url=authorize_url, status_code=302)
-
- async def logout(self, *args, **kwargs) -> RedirectResponse:
- """
- Handle logout by clearing session and redirecting.
-
- Args:
- request: Starlette request object (extracted from args/kwargs)
- *args/**kwargs: Ignored, kept for compatibility with different
- Starlette Admin logout call signatures
-
- Returns:
- RedirectResponse to home page
- """
- request: Optional[Request] = kwargs.get("request")
- if request is None:
- for arg in args:
- if isinstance(arg, Request):
- request = arg
- break
-
- if request is None:
- raise LoginFailed("Unable to determine logout request context.")
-
- # Clear session tokens
- request.session.pop("token", None)
- request.session.pop("auth_redirect", None)
-
- # Clear user from request state
- if hasattr(request.state, "user"):
- delattr(request.state, "user")
-
- # Redirect to home page
- # TODO: Consider redirecting to Authentik logout endpoint to fully log out
- return RedirectResponse(url="/", status_code=302)
diff --git a/admin/auth_routes.py b/admin/auth_routes.py
deleted file mode 100644
index 9db20669e..000000000
--- a/admin/auth_routes.py
+++ /dev/null
@@ -1,78 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-Admin authentication callback routes.
-"""
-
-import os
-
-import httpx
-from fastapi import APIRouter, Request
-from starlette.responses import RedirectResponse
-from starlette_admin.exceptions import LoginFailed
-
-router = APIRouter()
-
-
-@router.get("/admin/auth/callback", name="admin_auth_callback", include_in_schema=False)
-async def admin_auth_callback(request: Request):
- code = request.query_params.get("code")
- state = request.query_params.get("state")
- expected_state = request.session.get("auth_state")
-
- if not code or not state or state != expected_state:
- raise LoginFailed("Invalid authentication response.")
-
- token_url = os.environ.get("AUTHENTIK_TOKEN_URL")
- client_id = os.environ.get("AUTHENTIK_CLIENT_ID")
- if not token_url or not client_id:
- raise LoginFailed(
- "Authentik authentication is not configured. Please set AUTHENTIK_TOKEN_URL and AUTHENTIK_CLIENT_ID."
- )
-
- redirect_uri = str(request.url_for("admin_auth_callback"))
- code_verifier = request.session.get("auth_code_verifier")
-
- data = {
- "grant_type": "authorization_code",
- "client_id": client_id,
- "code": code,
- "redirect_uri": redirect_uri,
- }
-
- if code_verifier:
- data["code_verifier"] = code_verifier
-
- client_secret = os.environ.get("AUTHENTIK_CLIENT_SECRET")
- if client_secret:
- data["client_secret"] = client_secret
-
- async with httpx.AsyncClient(timeout=15.0) as client:
- resp = await client.post(token_url, data=data)
- if resp.status_code >= 400:
- raise LoginFailed("Failed to exchange token from Authentik.")
- token_payload = resp.json()
-
- access_token = token_payload.get("access_token")
- if not access_token:
- raise LoginFailed("Authentik did not return an access token.")
-
- request.session["token"] = access_token
- request.session.pop("auth_state", None)
- request.session.pop("auth_code_verifier", None)
-
- redirect_to = request.session.pop("auth_redirect", "/admin")
- return RedirectResponse(url=redirect_to, status_code=302)
diff --git a/admin/config.py b/admin/config.py
deleted file mode 100644
index e559fef92..000000000
--- a/admin/config.py
+++ /dev/null
@@ -1,218 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-Starlette Admin configuration and initialization.
-
-This module creates and configures the admin interface for OcotilloAPI.
-"""
-
-from admin.auth import NMSampleLocationsAuthProvider
-from admin.views import (
- AquiferSystemAdmin,
- AquiferTypeAdmin,
- AssetAdmin,
- AssociatedDataAdmin,
- ChemistrySampleInfoAdmin,
- ContactAdmin,
- DataProvenanceAdmin,
- DeploymentAdmin,
- FieldActivityAdmin,
- FieldEventAdmin,
- GeologicFormationAdmin,
- GroupAdmin,
- HydraulicsDataAdmin,
- LexiconCategoryAdmin,
- LexiconTermAdmin,
- LocationAdmin,
- MajorChemistryAdmin,
- MinorTraceChemistryAdmin,
- NotesAdmin,
- ObservationAdmin,
- ParameterAdmin,
- RadionuclidesAdmin,
- SampleAdmin,
- SensorAdmin,
- SoilRockResultsAdmin,
- StratigraphyAdmin,
- SurfaceWaterDataAdmin,
- SurfaceWaterPhotosAdmin,
- ThingAdmin,
- TransducerObservationAdmin,
- WaterLevelsContinuousPressureDailyAdmin,
- WeatherPhotosAdmin,
- WeatherDataAdmin,
- FieldParametersAdmin,
-)
-from db import NMA_FieldParameters
-from db.aquifer_system import AquiferSystem
-from db.aquifer_type import AquiferType
-from db.asset import Asset
-from db.contact import Contact
-from db.data_provenance import DataProvenance
-from db.deployment import Deployment
-from db.engine import engine
-from db.field import FieldActivity, FieldEvent
-from db.geologic_formation import GeologicFormation
-from db.group import Group
-from db.lexicon import LexiconCategory, LexiconTerm
-from db.location import Location
-from db.nma_legacy import (
- NMA_AssociatedData,
- NMA_Chemistry_SampleInfo,
- NMA_MajorChemistry,
- NMA_MinorTraceChemistry,
- NMA_Radionuclides,
- NMA_HydraulicsData,
- NMA_Soil_Rock_Results,
- NMA_Stratigraphy,
- NMA_SurfaceWaterData,
- NMA_WaterLevelsContinuous_Pressure_Daily,
- NMA_WeatherPhotos,
- NMA_SurfaceWaterPhotos,
- NMA_WeatherData,
-)
-from db.notes import Notes
-from db.observation import Observation
-from db.parameter import Parameter
-from db.sample import Sample
-from db.sensor import Sensor
-from db.thing import Thing
-from db.transducer import TransducerObservation
-from starlette_admin.contrib.sqla import Admin
-
-
-def create_admin(app):
- """
- Create and configure Starlette Admin instance.
-
- This function sets up the admin interface and mounts it to the FastAPI app
- at the /admin route.
-
- For MS Access users: This replaces the Access database file with a web-based
- admin interface. Instead of opening a .accdb file, staff will navigate to
- https://your-domain.com/admin in their web browser.
-
- Args:
- app: FastAPI application instance
-
- Returns:
- Admin: Configured Starlette Admin instance
- """
- # Create admin instance
- admin = Admin(
- engine=engine,
- title="Ocotillod Admin",
- base_url="/admin",
- logo_url=None, # TODO: Add NMBGMR logo
- auth_provider=NMSampleLocationsAuthProvider(),
- middlewares=[], # Add custom middlewares here if needed
- )
-
- # Register model views
- # Assets
- admin.add_view(AssetAdmin(Asset))
-
- # Aquifer
- admin.add_view(AquiferSystemAdmin(AquiferSystem))
- admin.add_view(AquiferTypeAdmin(AquiferType))
-
- # Contacts
- admin.add_view(ContactAdmin(Contact))
-
- # Data provenance
- admin.add_view(DataProvenanceAdmin(DataProvenance))
-
- # Deployment / Equipment
- admin.add_view(DeploymentAdmin(Deployment))
- admin.add_view(SensorAdmin(Sensor))
-
- # Field
- admin.add_view(FieldActivityAdmin(FieldActivity))
- admin.add_view(FieldEventAdmin(FieldEvent))
-
- # Geology
- admin.add_view(GeologicFormationAdmin(GeologicFormation))
-
- # Geography
- admin.add_view(LocationAdmin(Location))
- # Associated data
- admin.add_view(AssociatedDataAdmin(NMA_AssociatedData))
-
- # Aquifer
- admin.add_view(AquiferSystemAdmin(AquiferSystem))
- admin.add_view(AquiferTypeAdmin(AquiferType))
-
- # Groups
- admin.add_view(GroupAdmin(Group))
-
- # Hydraulics
- admin.add_view(HydraulicsDataAdmin(NMA_HydraulicsData))
- admin.add_view(MinorTraceChemistryAdmin(NMA_MinorTraceChemistry))
- admin.add_view(RadionuclidesAdmin(NMA_Radionuclides))
- admin.add_view(MajorChemistryAdmin(NMA_MajorChemistry))
-
- # Lexicon
- admin.add_view(LexiconCategoryAdmin(LexiconCategory))
- admin.add_view(LexiconTermAdmin(LexiconTerm))
-
- # Notes
- admin.add_view(NotesAdmin(Notes))
-
- # Observations
- admin.add_view(ObservationAdmin(Observation))
-
- # Parameters
- admin.add_view(ParameterAdmin(Parameter))
- admin.add_view(FieldParametersAdmin(NMA_FieldParameters))
-
- # Samples
- admin.add_view(ChemistrySampleInfoAdmin(NMA_Chemistry_SampleInfo))
- admin.add_view(SampleAdmin(Sample))
- admin.add_view(SurfaceWaterDataAdmin(NMA_SurfaceWaterData))
-
- # Soil & Stratigraphy
- admin.add_view(SoilRockResultsAdmin(NMA_Soil_Rock_Results))
- admin.add_view(StratigraphyAdmin(NMA_Stratigraphy))
-
- # Things (Wells, Springs, etc.)
- admin.add_view(ThingAdmin(Thing))
-
- # Transducer observations
- admin.add_view(TransducerObservationAdmin(TransducerObservation))
-
- # Water Levels - Continuous (legacy)
- admin.add_view(
- WaterLevelsContinuousPressureDailyAdmin(
- NMA_WaterLevelsContinuous_Pressure_Daily
- )
- )
-
- # Weather
- admin.add_view(WeatherPhotosAdmin(NMA_WeatherPhotos))
-
- # Surface Water Photos
- admin.add_view(SurfaceWaterPhotosAdmin(NMA_SurfaceWaterPhotos))
- # Weather
- admin.add_view(WeatherDataAdmin(NMA_WeatherData))
-
- # Future: Add more views here as they are implemented
- # admin.add_view(SampleAdmin)
- # admin.add_view(GroupAdmin)
-
- # Mount admin to app
- admin.mount_to(app)
-
- return admin
diff --git a/admin/fields.py b/admin/fields.py
deleted file mode 100644
index 9da16f9e9..000000000
--- a/admin/fields.py
+++ /dev/null
@@ -1,141 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-Custom fields for Starlette Admin.
-
-Provides field handlers for complex data types like PostGIS geometry.
-"""
-
-from typing import Any
-
-from geoalchemy2 import WKTElement
-from geoalchemy2.shape import to_shape
-from starlette.requests import Request
-from starlette_admin import StringField
-
-from core.constants import SRID_WGS84
-
-
-class WKTField(StringField):
- """
- Custom field for GeoAlchemy2 Geometry columns.
-
- This field converts between PostGIS geometry (WKBElement) and human-readable
- WKT (Well-Known Text) format for display and editing in the admin interface.
-
- For MS Access users: Instead of entering Easting/Northing/UTM Zone in separate
- fields, you'll enter coordinates in WKT format, for example:
- POINT(-106.123 35.456)
-
- Note: Longitude comes first, then latitude (POINT(lon lat), not POINT(lat lon))
- """
-
- async def serialize_value(self, request: Request, value: Any, action: str) -> str:
- """
- Convert WKBElement (PostGIS geometry) to WKT string for display in form.
-
- This is called when rendering the edit/create form to show the current
- value in a text input.
-
- Args:
- request: Starlette request object
- value: WKBElement from database (PostGIS geometry)
- action: 'list', 'detail', 'edit', or 'create'
-
- Returns:
- WKT string representation of geometry (e.g., "POINT(-106.123 35.456)")
- """
- if value is None:
- return ""
-
- try:
- # Convert WKBElement to Shapely geometry, then to WKT
- shape = to_shape(value)
- return shape.wkt
- except Exception:
- # If conversion fails, return string representation
- return str(value)
-
- async def parse_form_data(
- self, request: Request, form_data: dict, action: str
- ) -> Any:
- """
- Convert WKT string from form input to WKTElement for database storage.
-
- This is called when saving the form to convert the user's input into
- a format that can be stored in the PostGIS database.
-
- Args:
- request: Starlette request object
- form_data: Dictionary of form data
- action: 'edit' or 'create'
-
- Returns:
- WKTElement with SRID for PostGIS storage, or None if empty
-
- Raises:
- ValueError: If WKT string is invalid
- """
- wkt_string = form_data.get(self.name)
-
- if not wkt_string or wkt_string.strip() == "":
- return None
-
- try:
- # Parse and validate WKT string
- from shapely.wkt import loads as wkt_loads
-
- shape = wkt_loads(wkt_string.strip())
-
- # Convert to WKTElement with SRID (spatial reference identifier)
- return WKTElement(shape.wkt, srid=SRID_WGS84)
- except Exception as e:
- raise ValueError(
- f"Invalid WKT geometry: {e}. "
- f"Expected format: POINT(longitude latitude), e.g., POINT(-106.123 35.456). "
- f"Note: Longitude comes first, then latitude."
- )
-
-
-class CoordinateHelpField(WKTField):
- """
- Extended WKT field with detailed help text for coordinate entry.
-
- This version includes comprehensive help text for users transitioning
- from MS Access UTM coordinate entry to WKT format.
- """
-
- def __init__(self, *args, **kwargs):
- # Add detailed help text if not provided
- if "help_text" not in kwargs:
- kwargs["help_text"] = (
- "Enter coordinates in WKT (Well-Known Text) format.\n\n"
- "Format: POINT(longitude latitude)\n"
- "Example: POINT(-106.65082 35.08352)\n\n"
- "Important:\n"
- " * Longitude comes FIRST (negative for western hemisphere)\n"
- " * Latitude comes SECOND\n"
- " * No comma between values\n"
- " * Use decimal degrees (not degrees-minutes-seconds)\n"
- " * Coordinate system: WGS84 (SRID 4326)\n\n"
- "If you have UTM coordinates:\n"
- " 1. Use an online converter (e.g., https://www.latlong.net/utm-to-lat-long)\n"
- " 2. Enter your Easting, Northing, and UTM Zone\n"
- " 3. Convert to WGS84 lat/lon\n"
- " 4. Enter here as POINT(lon lat)"
- )
-
- super().__init__(*args, **kwargs)
diff --git a/admin/views/__init__.py b/admin/views/__init__.py
deleted file mode 100644
index c8d0f5ad2..000000000
--- a/admin/views/__init__.py
+++ /dev/null
@@ -1,97 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-Admin views package for OcotilloAPI.
-
-Provides MS Access-like interface for CRUD operations on database models.
-"""
-
-from admin.views.aquifer_system import AquiferSystemAdmin
-from admin.views.aquifer_type import AquiferTypeAdmin
-from admin.views.asset import AssetAdmin
-from admin.views.associated_data import AssociatedDataAdmin
-from admin.views.chemistry_sampleinfo import ChemistrySampleInfoAdmin
-from admin.views.contact import ContactAdmin
-from admin.views.data_provenance import DataProvenanceAdmin
-from admin.views.deployment import DeploymentAdmin
-from admin.views.field import (
- FieldActivityAdmin,
- FieldEventAdmin,
- FieldEventParticipantAdmin,
-)
-from admin.views.field_parameters import FieldParametersAdmin
-from admin.views.geologic_formation import GeologicFormationAdmin
-from admin.views.group import GroupAdmin
-from admin.views.hydraulicsdata import HydraulicsDataAdmin
-from admin.views.lexicon import LexiconCategoryAdmin, LexiconTermAdmin
-from admin.views.location import LocationAdmin
-from admin.views.major_chemistry import MajorChemistryAdmin
-from admin.views.minor_trace_chemistry import MinorTraceChemistryAdmin
-from admin.views.notes import NotesAdmin
-from admin.views.observation import ObservationAdmin
-from admin.views.parameter import ParameterAdmin
-from admin.views.radionuclides import RadionuclidesAdmin
-from admin.views.sample import SampleAdmin
-from admin.views.sensor import SensorAdmin
-from admin.views.soil_rock_results import SoilRockResultsAdmin
-from admin.views.stratigraphy import StratigraphyAdmin
-from admin.views.surface_water import SurfaceWaterDataAdmin
-from admin.views.surface_water_photos import SurfaceWaterPhotosAdmin
-from admin.views.thing import ThingAdmin
-from admin.views.transducer_observation import TransducerObservationAdmin
-from admin.views.waterlevelscontinuous_pressure_daily import (
- WaterLevelsContinuousPressureDailyAdmin,
-)
-from admin.views.weather_data import WeatherDataAdmin
-from admin.views.weather_photos import WeatherPhotosAdmin
-
-__all__ = [
- "AssetAdmin",
- "AssociatedDataAdmin",
- "AquiferSystemAdmin",
- "AquiferTypeAdmin",
- "ChemistrySampleInfoAdmin",
- "ContactAdmin",
- "DataProvenanceAdmin",
- "DeploymentAdmin",
- "FieldActivityAdmin",
- "FieldEventAdmin",
- "FieldEventParticipantAdmin",
- "FieldParametersAdmin",
- "GeologicFormationAdmin",
- "GroupAdmin",
- "HydraulicsDataAdmin",
- "LexiconCategoryAdmin",
- "LexiconTermAdmin",
- "LocationAdmin",
- "MajorChemistryAdmin",
- "MinorTraceChemistryAdmin",
- "NotesAdmin",
- "ObservationAdmin",
- "ParameterAdmin",
- "RadionuclidesAdmin",
- "SampleAdmin",
- "SensorAdmin",
- "SoilRockResultsAdmin",
- "StratigraphyAdmin",
- "SurfaceWaterDataAdmin",
- "SurfaceWaterPhotosAdmin",
- "ThingAdmin",
- "TransducerObservationAdmin",
- "WaterLevelsContinuousPressureDailyAdmin",
- "WeatherPhotosAdmin",
- "WeatherDataAdmin",
-]
diff --git a/admin/views/aquifer_system.py b/admin/views/aquifer_system.py
deleted file mode 100644
index 9b384e098..000000000
--- a/admin/views/aquifer_system.py
+++ /dev/null
@@ -1,94 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-AquiferSystemAdmin view for OcotilloAPI.
-"""
-
-from admin.fields import WKTField
-from admin.views.base import OcotilloModelView
-
-
-class AquiferSystemAdmin(OcotilloModelView):
- """
- Admin view for AquiferSystem model.
- """
-
- # ========== Basic Configuration ==========
-
- name = "Aquifer Systems"
- label = "Aquifer Systems"
- icon = "fa fa-globe"
-
- # ========== List View ==========
-
- sortable_fields = [
- "id",
- "name",
- "primary_aquifer_type",
- "geographic_scale",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("name", False)]
-
- searchable_fields = [
- "name",
- "description",
- "primary_aquifer_type",
- "geographic_scale",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "name",
- "description",
- "primary_aquifer_type",
- "geographic_scale",
- WKTField("boundary", label="Boundary (WKT)"),
- "release_status",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- ]
-
-
-# ============= EOF =============================================
diff --git a/admin/views/aquifer_type.py b/admin/views/aquifer_type.py
deleted file mode 100644
index ad319b6d3..000000000
--- a/admin/views/aquifer_type.py
+++ /dev/null
@@ -1,86 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-AquiferTypeAdmin view for OcotilloAPI.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class AquiferTypeAdmin(OcotilloModelView):
- """
- Admin view for AquiferType model.
- """
-
- # ========== Basic Configuration ==========
-
- name = "Aquifer Types"
- label = "Aquifer Types"
- icon = "fa fa-tint"
-
- # ========== List View ==========
-
- sortable_fields = [
- "id",
- "thing_aquifer_association_id",
- "aquifer_type",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("created_at", True)]
-
- searchable_fields = [
- "aquifer_type",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "thing_aquifer_association_id",
- "aquifer_type",
- "release_status",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- ]
-
-
-# ============= EOF =============================================
diff --git a/admin/views/asset.py b/admin/views/asset.py
deleted file mode 100644
index acec3bb80..000000000
--- a/admin/views/asset.py
+++ /dev/null
@@ -1,100 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-AssetAdmin view for OcotilloAPI.
-
-Provides MS Access-like interface for CRUD operations on Asset model.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class AssetAdmin(OcotilloModelView):
- """
- Admin view for Asset model.
- """
-
- # ========== Basic Configuration ==========
-
- name = "Assets"
- label = "Assets"
- icon = "fa fa-file"
-
- # ========== List View ==========
-
- sortable_fields = [
- "id",
- "name",
- "mime_type",
- "storage_service",
- "size",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("created_at", True)]
-
- searchable_fields = [
- "name",
- "label",
- "mime_type",
- "storage_service",
- "storage_path",
- "uri",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "name",
- "label",
- "storage_service",
- "storage_path",
- "mime_type",
- "size",
- "uri",
- "release_status",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- ]
-
-
-# ============= EOF =============================================
diff --git a/admin/views/associated_data.py b/admin/views/associated_data.py
deleted file mode 100644
index f58dcd628..000000000
--- a/admin/views/associated_data.py
+++ /dev/null
@@ -1,113 +0,0 @@
-# ===============================================================================
-# Copyright 2026
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-AssociatedDataAdmin view for legacy NMA_AssociatedData.
-
-Updated for Integer PK schema:
-- id: Integer PK (autoincrement)
-- nma_assoc_id: Legacy UUID PK (AssocID), UNIQUE for audit
-- nma_location_id: Legacy LocationId UUID, UNIQUE
-- nma_point_id: Legacy PointID string
-- nma_object_id: Legacy OBJECTID, UNIQUE
-"""
-
-from starlette.requests import Request
-
-from admin.views.base import OcotilloModelView
-
-
-class AssociatedDataAdmin(OcotilloModelView):
- """
- Admin view for legacy AssociatedData model (NMA_AssociatedData).
- Read-only, MS Access-like listing/details.
- """
-
- # ========== Basic Configuration ==========
- name = "NMA Associated Data"
- label = "NMA Associated Data"
- icon = "fa fa-link"
-
- # Integer PK
- pk_attr = "id"
- pk_type = int
-
- def can_create(self, request: Request) -> bool:
- return False
-
- def can_edit(self, request: Request) -> bool:
- return False
-
- def can_delete(self, request: Request) -> bool:
- return False
-
- # ========== List View ==========
-
- list_fields = [
- "id",
- "nma_assoc_id",
- "nma_location_id",
- "nma_point_id",
- "nma_object_id",
- "notes",
- "formation",
- "thing_id",
- ]
-
- sortable_fields = [
- "id",
- "nma_assoc_id",
- "nma_object_id",
- "nma_point_id",
- ]
-
- fields_default_sort = [("nma_point_id", False), ("nma_object_id", False)]
-
- searchable_fields = [
- "nma_point_id",
- "nma_assoc_id",
- "notes",
- "formation",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "nma_assoc_id",
- "nma_location_id",
- "nma_point_id",
- "nma_object_id",
- "notes",
- "formation",
- "thing_id",
- ]
-
- field_labels = {
- "id": "ID",
- "nma_assoc_id": "NMA AssocID (Legacy)",
- "nma_location_id": "NMA LocationId (Legacy)",
- "nma_point_id": "NMA PointID (Legacy)",
- "nma_object_id": "NMA OBJECTID (Legacy)",
- "notes": "Notes",
- "formation": "Formation",
- "thing_id": "Thing ID",
- }
-
-
-# ============= EOF =============================================
diff --git a/admin/views/base.py b/admin/views/base.py
deleted file mode 100644
index a44f51c53..000000000
--- a/admin/views/base.py
+++ /dev/null
@@ -1,142 +0,0 @@
-# ===============================================================================
-# Copyright 2026
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-from __future__ import annotations
-
-from typing import Any, Iterable, Sequence
-
-from sqlalchemy import select, update
-from starlette.requests import Request
-from starlette.responses import Response
-from starlette_admin import ExportType, action
-from starlette_admin.contrib.sqla import ModelView
-
-from db.engine import session_ctx
-
-
-class OcotilloModelView(ModelView):
- """
- Shared admin behaviors for Ocotillo data models.
-
- - RBAC: admin can create/edit/delete; editor can edit; any authenticated user can view.
- - Data visibility: non-admin/editor users only see published rows when a release field exists.
- - Publish/Unpublish actions: toggle release status when enabled and a release field is present.
- """
-
- release_field = "release_status"
- draft_value = "draft"
- published_value = "published"
- enable_publish_actions: bool = True
- export_types: Sequence[ExportType] = (ExportType.CSV, ExportType.EXCEL)
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # ========= Permissions (RBAC) =========
- def _get_user(self, request: Request) -> Any:
- return getattr(request.state, "user", None)
-
- def _roles(self, request: Request) -> list[str]:
- user = self._get_user(request)
- return getattr(user, "roles", []) if user else []
-
- def _has_role(self, request: Request, roles: Iterable[str]) -> bool:
- return bool(set(self._roles(request)) & set(roles))
-
- def can_create(self, request: Request) -> bool:
- return self._has_role(request, {"admin"})
-
- def can_edit(self, request: Request) -> bool:
- return self._has_role(request, {"admin", "editor"})
-
- def can_delete(self, request: Request) -> bool:
- return self._has_role(request, {"admin"})
-
- def can_view_details(self, request: Request) -> bool:
- return self._get_user(request) is not None
-
- # ========= Data Visibility =========
- def get_list_query(self, request: Request):
- query = select(self.model)
- user = self._get_user(request)
- if user is None:
- # Return an empty result set for anonymous users
- return query.where(self.model.id == -1)
-
- if not hasattr(self.model, self.release_field):
- return query
-
- if self._has_role(request, {"admin", "editor"}):
- return query
- return query.where(
- getattr(self.model, self.release_field) == self.published_value
- )
-
- # ========= Actions (Publish / Unpublish) =========
- def _ensure_release_field(self) -> bool:
- return self.enable_publish_actions and hasattr(self.model, self.release_field)
-
- @action(
- name="publish_selected",
- text="Publish Selected",
- confirmation="Are you sure you want to publish the selected records?",
- submit_btn_text="Yes, publish",
- submit_btn_class="btn btn-success",
- )
- async def publish_selected(self, request: Request, pks: list[int]) -> Response:
- if not self._has_role(request, {"admin"}):
- return Response("Only admins can publish", status_code=403)
- if not self._ensure_release_field():
- return Response(
- "Publish action not available for this model", status_code=400
- )
-
- with session_ctx() as session:
- result = session.execute(
- update(self.model)
- .where(self.model.id.in_(pks))
- .values({self.release_field: self.published_value})
- )
- session.commit()
- updated_count = result.rowcount
- return Response(f"Published {updated_count} record(s)", status_code=200)
-
- @action(
- name="unpublish_selected",
- text="Unpublish Selected (set to draft)",
- confirmation="Are you sure you want to unpublish the selected records?",
- submit_btn_text="Yes, unpublish",
- submit_btn_class="btn btn-warning",
- )
- async def unpublish_selected(self, request: Request, pks: list[int]) -> Response:
- if not self._has_role(request, {"admin"}):
- return Response("Only admins can unpublish", status_code=403)
- if not self._ensure_release_field():
- return Response(
- "Unpublish action not available for this model", status_code=400
- )
-
- with session_ctx() as session:
- result = session.execute(
- update(self.model)
- .where(self.model.id.in_(pks))
- .values({self.release_field: self.draft_value})
- )
- session.commit()
- updated_count = result.rowcount
- return Response(f"Unpublished {updated_count} record(s)", status_code=200)
-
-
-# ============= EOF =============================================
diff --git a/admin/views/chemistry_sampleinfo.py b/admin/views/chemistry_sampleinfo.py
deleted file mode 100644
index b588da038..000000000
--- a/admin/views/chemistry_sampleinfo.py
+++ /dev/null
@@ -1,175 +0,0 @@
-# ===============================================================================
-# Copyright 2026
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-ChemistrySampleInfoAdmin view for legacy Chemistry_SampleInfo.
-
-Updated for Integer PK schema:
-- id: Integer PK (autoincrement)
-- nma_sample_pt_id: Legacy UUID PK (SamplePtID), UNIQUE for audit
-- nma_wclab_id: Legacy WCLab_ID
-- nma_sample_point_id: Legacy SamplePointID
-- nma_object_id: Legacy OBJECTID, UNIQUE
-- nma_location_id: Legacy LocationId UUID (for audit trail)
-
-FK Change (2026-01):
-- thing_id: Integer FK to Thing.id
-"""
-
-from starlette.requests import Request
-from starlette_admin.fields import HasOne
-
-from admin.views.base import OcotilloModelView
-
-
-class ChemistrySampleInfoAdmin(OcotilloModelView):
- """
- Admin view for ChemistrySampleInfo model.
- """
-
- # ========== Basic Configuration ==========
-
- name = "NMA Chemistry Sample Info"
- label = "NMA Chemistry Sample Info"
- icon = "fa fa-flask"
-
- # Integer PK
- pk_attr = "id"
- pk_type = int
-
- def can_create(self, request: Request) -> bool:
- return False
-
- def can_edit(self, request: Request) -> bool:
- return False
-
- def can_delete(self, request: Request) -> bool:
- return False
-
- # ========== List View ==========
-
- list_fields = [
- "id",
- "nma_sample_pt_id",
- "nma_wclab_id",
- "nma_sample_point_id",
- "nma_object_id",
- "nma_location_id",
- "thing_id",
- HasOne("thing", identity="thing"),
- "collection_date",
- "collection_method",
- "collected_by",
- "analyses_agency",
- "sample_type",
- "sample_material_not_h2o",
- "water_type",
- "study_sample",
- "data_source",
- "data_quality",
- "public_release",
- "added_day_to_date",
- "added_month_day_to_date",
- "sample_notes",
- ]
-
- sortable_fields = [
- "id",
- "nma_sample_pt_id",
- "nma_wclab_id",
- "nma_sample_point_id",
- "nma_object_id",
- "collection_date",
- "sample_type",
- "data_source",
- "data_quality",
- "public_release",
- ]
-
- fields_default_sort = [("collection_date", True)]
-
- searchable_fields = [
- "nma_sample_pt_id",
- "nma_wclab_id",
- "nma_sample_point_id",
- "collection_date",
- "collected_by",
- "analyses_agency",
- "sample_type",
- "sample_material_not_h2o",
- "water_type",
- "study_sample",
- "data_source",
- "data_quality",
- "public_release",
- "sample_notes",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "nma_sample_pt_id",
- "nma_wclab_id",
- "nma_sample_point_id",
- "nma_object_id",
- "nma_location_id",
- "thing_id",
- HasOne("thing", identity="thing"),
- "collection_date",
- "collection_method",
- "collected_by",
- "analyses_agency",
- "sample_type",
- "sample_material_not_h2o",
- "water_type",
- "study_sample",
- "data_source",
- "data_quality",
- "public_release",
- "added_day_to_date",
- "added_month_day_to_date",
- "sample_notes",
- ]
-
- field_labels = {
- "id": "ID",
- "nma_sample_pt_id": "NMA SamplePtID (Legacy)",
- "nma_wclab_id": "NMA WCLab_ID (Legacy)",
- "nma_sample_point_id": "NMA SamplePointID (Legacy)",
- "nma_object_id": "NMA OBJECTID (Legacy)",
- "nma_location_id": "NMA LocationId (Legacy)",
- "thing_id": "Thing ID",
- "collection_date": "Collection Date",
- "collection_method": "Collection Method",
- "collected_by": "Collected By",
- "analyses_agency": "Analyses Agency",
- "sample_type": "Sample Type",
- "sample_material_not_h2o": "Sample Material Not H2O",
- "water_type": "Water Type",
- "study_sample": "Study Sample",
- "data_source": "Data Source",
- "data_quality": "Data Quality",
- "public_release": "Public Release",
- "added_day_to_date": "Added Day to Date",
- "added_month_day_to_date": "Added Month/Day to Date",
- "sample_notes": "Sample Notes",
- }
-
-
-# ============= EOF =============================================
diff --git a/admin/views/contact.py b/admin/views/contact.py
deleted file mode 100644
index 36bea8ee4..000000000
--- a/admin/views/contact.py
+++ /dev/null
@@ -1,129 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-ContactAdmin view for OcotilloAPI.
-
-Provides MS Access-like interface for CRUD operations on Contact (Owners) model.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class ContactAdmin(OcotilloModelView):
- """
- Admin view for Contact model (Well Owners/Managers).
-
- Designed to replicate MS Access "Owners Data Entry Form" and "Owners Datasheet View".
-
- Permission Model:
- - Admin: Can create, edit, delete all contacts
- - Editor: Can create and edit, cannot delete
- - Viewer: Can only view published contacts (read-only)
- """
-
- # ========== Basic Configuration ==========
-
- name = "Contacts"
- label = "Contacts (Owners)"
- icon = "fa fa-users"
-
- # ========== List View (MS Access Datasheet View Equivalent) ==========
-
- sortable_fields = [
- "id",
- "name",
- "organization",
- "role",
- "contact_type",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("name", False)] # Alphabetical by name
-
- searchable_fields = [
- "name",
- "organization",
- "role",
- "contact_type",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View (MS Access Form View Equivalent) ==========
-
- fields = [
- "id",
- # Contact Information
- "name",
- "organization",
- "role",
- "contact_type",
- # Release Status
- "release_status",
- # Audit Fields
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- # Legacy Migration Fields
- "nma_pk_owners",
- "nma_pk_waterlevels",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- "nma_pk_owners",
- "nma_pk_waterlevels",
- # Exclude complex relationships (manage separately)
- "phones",
- "emails",
- "addresses",
- "incomplete_nma_phones",
- "permissions",
- "author_associations",
- "thing_associations",
- "field_event_participants",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "nma_pk_owners",
- "nma_pk_waterlevels",
- # Exclude complex relationships (manage separately)
- "phones",
- "emails",
- "addresses",
- "incomplete_nma_phones",
- "permissions",
- "author_associations",
- "thing_associations",
- "field_event_participants",
- ]
-
- # ========== Field Labels and Help Text ==========
diff --git a/admin/views/data_provenance.py b/admin/views/data_provenance.py
deleted file mode 100644
index c1a91551f..000000000
--- a/admin/views/data_provenance.py
+++ /dev/null
@@ -1,94 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-DataProvenanceAdmin view for OcotilloAPI.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class DataProvenanceAdmin(OcotilloModelView):
- """
- Admin view for DataProvenance model.
- """
-
- name = "Data Provenance"
- label = "Data Provenance"
- icon = "fa fa-history"
-
- sortable_fields = [
- "id",
- "target_table",
- "target_id",
- "field_name",
- "origin_type",
- "collection_method",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("created_at", True)]
-
- searchable_fields = [
- "target_table",
- "field_name",
- "origin_source",
- "origin_type",
- "collection_method",
- "accuracy_unit",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- fields = [
- "id",
- "target_table",
- "target_id",
- "field_name",
- "origin_type",
- "origin_source",
- "collection_method",
- "accuracy_value",
- "accuracy_unit",
- "release_status",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- ]
-
-
-# ============= EOF =============================================
diff --git a/admin/views/deployment.py b/admin/views/deployment.py
deleted file mode 100644
index ccdf535da..000000000
--- a/admin/views/deployment.py
+++ /dev/null
@@ -1,138 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-DeploymentAdmin view for OcotilloAPI.
-
-Provides MS Access-like interface for CRUD operations on Deployment model.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class DeploymentAdmin(OcotilloModelView):
- """
- Admin view for Deployment model (Equipment Installation Log).
-
- Designed to replicate MS Access "Equipment Deployment Form" and "Deployment Datasheet View".
-
- Permission Model:
- - Admin: Can create, edit, delete all deployments
- - Editor: Can create and edit, cannot delete
- - Viewer: Can only view published deployments (read-only)
- """
-
- # ========== Basic Configuration ==========
-
- name = "Deployments"
- label = "Deployments (Equipment Installations)"
- icon = "fa fa-plug"
-
- # ========== List View (MS Access Datasheet View Equivalent) ==========
-
- sortable_fields = [
- "id",
- "thing_id",
- "sensor_id",
- "installation_date",
- "removal_date",
- "recording_interval",
- "release_status",
- "created_at",
- "nma_WI_Duration",
- "nma_WI_EndFrequency",
- "nma_WI_Magnitude",
- "nma_WI_MicGain",
- "nma_WI_MinSoundDepth",
- "nma_WI_StartFrequency",
- ]
-
- fields_default_sort = [
- ("installation_date", True)
- ] # True = descending (newest first)
-
- searchable_fields = [
- "hanging_point_description",
- "notes",
- "installation_date",
- "removal_date",
- "recording_interval_units",
- "release_status",
- "created_at",
- "nma_WI_Duration",
- "nma_WI_EndFrequency",
- "nma_WI_Magnitude",
- "nma_WI_MicGain",
- "nma_WI_MinSoundDepth",
- "nma_WI_StartFrequency",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View (MS Access Form View Equivalent) ==========
-
- fields = [
- "id",
- # Deployment Information
- "thing_id",
- "sensor_id",
- "installation_date",
- "removal_date",
- "recording_interval",
- "recording_interval_units",
- "hanging_cable_length",
- "hanging_point_height",
- "hanging_point_description",
- "notes",
- "nma_WI_Duration",
- "nma_WI_EndFrequency",
- "nma_WI_Magnitude",
- "nma_WI_MicGain",
- "nma_WI_MinSoundDepth",
- "nma_WI_StartFrequency",
- # Release Status
- "release_status",
- # Audit Fields
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- # Exclude relationship objects (use IDs instead)
- "thing",
- "sensor",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- # Exclude relationship objects (use IDs instead)
- "thing",
- "sensor",
- ]
-
- # ========== Field Labels and Help Text ==========
diff --git a/admin/views/field.py b/admin/views/field.py
deleted file mode 100644
index 43a7b2cb5..000000000
--- a/admin/views/field.py
+++ /dev/null
@@ -1,199 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-Field admin views for OcotilloAPI.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class FieldEventAdmin(OcotilloModelView):
- """
- Admin view for FieldEvent model.
- """
-
- name = "Field Events"
- label = "Field Events"
- icon = "fa fa-calendar"
-
- sortable_fields = [
- "id",
- "thing_id",
- "event_date",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("event_date", True)]
-
- searchable_fields = [
- "notes",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- fields = [
- "id",
- "thing_id",
- "event_date",
- "notes",
- "release_status",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- ]
-
-
-class FieldActivityAdmin(OcotilloModelView):
- """
- Admin view for FieldActivity model.
- """
-
- name = "Field Activities"
- label = "Field Activities"
- icon = "fa fa-tasks"
-
- sortable_fields = [
- "id",
- "field_event_id",
- "activity_type",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("created_at", True)]
-
- searchable_fields = [
- "notes",
- "activity_type",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- fields = [
- "id",
- "field_event_id",
- "activity_type",
- "notes",
- "release_status",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- ]
-
-
-class FieldEventParticipantAdmin(OcotilloModelView):
- """
- Admin view for FieldEventParticipant model.
- """
-
- name = "Field Event Participants"
- label = "Field Event Participants"
- icon = "fa fa-users"
-
- sortable_fields = [
- "id",
- "field_event_id",
- "contact_id",
- "participant_role",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("created_at", True)]
-
- searchable_fields = [
- "participant_role",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- fields = [
- "id",
- "field_event_id",
- "contact_id",
- "participant_role",
- "release_status",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- ]
-
-
-# ============= EOF =============================================
diff --git a/admin/views/field_parameters.py b/admin/views/field_parameters.py
deleted file mode 100644
index 5638370cc..000000000
--- a/admin/views/field_parameters.py
+++ /dev/null
@@ -1,139 +0,0 @@
-# ===============================================================================
-# Copyright 2026
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-FieldParametersAdmin view for legacy NMA_FieldParameters.
-
-Updated for Integer PK schema:
-- id: Integer PK (autoincrement)
-- nma_global_id: Legacy UUID PK (GlobalID), UNIQUE for audit
-- chemistry_sample_info_id: Integer FK to NMA_Chemistry_SampleInfo.id
-- nma_sample_pt_id: Legacy UUID FK (SamplePtID) for audit
-- nma_sample_point_id: Legacy SamplePointID string
-- nma_object_id: Legacy OBJECTID
-- nma_wclab_id: Legacy WCLab_ID
-"""
-
-from starlette.requests import Request
-
-from admin.views.base import OcotilloModelView
-
-
-class FieldParametersAdmin(OcotilloModelView):
- """
- Admin view for FieldParameters model.
- """
-
- # ========== Basic Configuration ==========
-
- name = "NMA Field Parameters"
- label = "NMA Field Parameters"
- icon = "fa fa-tachometer"
-
- # Integer PK
- pk_attr = "id"
- pk_type = int
-
- def can_create(self, request: Request) -> bool:
- return False
-
- def can_edit(self, request: Request) -> bool:
- return False
-
- def can_delete(self, request: Request) -> bool:
- return False
-
- # ========== List View ==========
-
- list_fields = [
- "id",
- "nma_global_id",
- "chemistry_sample_info_id",
- "nma_sample_pt_id",
- "nma_sample_point_id",
- "field_parameter",
- "sample_value",
- "units",
- "notes",
- "analyses_agency",
- "nma_wclab_id",
- "nma_object_id",
- ]
-
- sortable_fields = [
- "id",
- "nma_global_id",
- "chemistry_sample_info_id",
- "nma_sample_pt_id",
- "nma_sample_point_id",
- "field_parameter",
- "sample_value",
- "units",
- "notes",
- "analyses_agency",
- "nma_wclab_id",
- "nma_object_id",
- ]
-
- fields_default_sort = [("nma_sample_point_id", True)]
-
- searchable_fields = [
- "nma_global_id",
- "nma_sample_pt_id",
- "nma_sample_point_id",
- "field_parameter",
- "units",
- "notes",
- "analyses_agency",
- "nma_wclab_id",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "nma_global_id",
- "chemistry_sample_info_id",
- "nma_sample_pt_id",
- "nma_sample_point_id",
- "field_parameter",
- "sample_value",
- "units",
- "notes",
- "nma_object_id",
- "analyses_agency",
- "nma_wclab_id",
- ]
-
- field_labels = {
- "id": "ID",
- "nma_global_id": "NMA GlobalID (Legacy)",
- "chemistry_sample_info_id": "Chemistry Sample Info ID",
- "nma_sample_pt_id": "NMA SamplePtID (Legacy)",
- "nma_sample_point_id": "NMA SamplePointID (Legacy)",
- "field_parameter": "FieldParameter",
- "sample_value": "SampleValue",
- "units": "Units",
- "notes": "Notes",
- "nma_object_id": "NMA OBJECTID (Legacy)",
- "analyses_agency": "AnalysesAgency",
- "nma_wclab_id": "NMA WCLab_ID (Legacy)",
- }
-
-
-# ============= EOF =============================================
diff --git a/admin/views/geologic_formation.py b/admin/views/geologic_formation.py
deleted file mode 100644
index bb6212026..000000000
--- a/admin/views/geologic_formation.py
+++ /dev/null
@@ -1,85 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-GeologicFormationAdmin view for OcotilloAPI.
-"""
-
-from admin.fields import WKTField
-from admin.views.base import OcotilloModelView
-
-
-class GeologicFormationAdmin(OcotilloModelView):
- """
- Admin view for GeologicFormation model.
- """
-
- name = "Geologic Formations"
- label = "Geologic Formations"
- icon = "fa fa-layer-group"
-
- sortable_fields = [
- "id",
- "formation_code",
- "lithology",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("formation_code", False)]
-
- searchable_fields = [
- "formation_code",
- "description",
- "lithology",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- fields = [
- "id",
- "formation_code",
- "description",
- "lithology",
- WKTField("boundary", label="Boundary (WKT)"),
- "release_status",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- ]
-
-
-# ============= EOF =============================================
diff --git a/admin/views/group.py b/admin/views/group.py
deleted file mode 100644
index f06a9ab76..000000000
--- a/admin/views/group.py
+++ /dev/null
@@ -1,93 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-GroupAdmin view for OcotilloAPI.
-"""
-
-from admin.fields import WKTField
-from admin.views.base import OcotilloModelView
-
-
-class GroupAdmin(OcotilloModelView):
- """
- Admin view for Group model.
- """
-
- # ========== Basic Configuration ==========
-
- name = "Groups"
- label = "Groups"
- icon = "fa fa-object-group"
-
- # ========== List View ==========
-
- sortable_fields = [
- "id",
- "name",
- "group_type",
- "parent_group_id",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("name", False)]
-
- searchable_fields = [
- "name",
- "description",
- "group_type",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "name",
- "description",
- "group_type",
- "parent_group_id",
- WKTField("project_area", label="Project Area (WKT)"),
- "release_status",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- ]
-
-
-# ============= EOF =============================================
diff --git a/admin/views/hydraulicsdata.py b/admin/views/hydraulicsdata.py
deleted file mode 100644
index 9723cbb38..000000000
--- a/admin/views/hydraulicsdata.py
+++ /dev/null
@@ -1,149 +0,0 @@
-# ===============================================================================
-# Copyright 2026
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-HydraulicsDataAdmin view for legacy NMA_HydraulicsData.
-
-Updated for Integer PK schema:
-- id: Integer PK (autoincrement)
-- nma_global_id: Legacy UUID PK (GlobalID), UNIQUE for audit
-- nma_well_id: Legacy WellID UUID
-- nma_point_id: Legacy PointID string
-- nma_object_id: Legacy OBJECTID, UNIQUE
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class HydraulicsDataAdmin(OcotilloModelView):
- """
- Admin view for NMA_HydraulicsData model.
- """
-
- # ========== Basic Configuration ==========
-
- name = "Hydraulics Data"
- label = "Hydraulics Data"
- icon = "fa fa-tint"
-
- # Integer PK
- pk_attr = "id"
- pk_type = int
-
- can_create = False
- can_edit = False
- can_delete = False
-
- # ========== List View ==========
-
- list_fields = [
- "id",
- "nma_global_id",
- "nma_well_id",
- "nma_point_id",
- "thing_id",
- "hydraulic_unit",
- "hydraulic_unit_type",
- "test_top",
- "test_bottom",
- "t_ft2_d",
- "k_darcy",
- "data_source",
- "nma_object_id",
- ]
-
- sortable_fields = [
- "id",
- "nma_global_id",
- "nma_well_id",
- "nma_point_id",
- "thing_id",
- "hydraulic_unit",
- "hydraulic_unit_type",
- "test_top",
- "test_bottom",
- "t_ft2_d",
- "k_darcy",
- "data_source",
- "nma_object_id",
- ]
-
- searchable_fields = [
- "nma_global_id",
- "nma_point_id",
- "hydraulic_unit",
- "hydraulic_remarks",
- "data_source",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "nma_global_id",
- "nma_well_id",
- "nma_point_id",
- "thing_id",
- "hydraulic_unit",
- "hydraulic_unit_type",
- "hydraulic_remarks",
- "test_top",
- "test_bottom",
- "t_ft2_d",
- "s_dimensionless",
- "ss_ft_1",
- "sy_decimalfractn",
- "kh_ft_d",
- "kv_ft_d",
- "hl_day_1",
- "hd_ft2_d",
- "cs_gal_d_ft",
- "p_decimal_fraction",
- "k_darcy",
- "data_source",
- "nma_object_id",
- ]
-
- field_labels = {
- "id": "ID",
- "nma_global_id": "NMA GlobalID (Legacy)",
- "nma_well_id": "NMA WellID (Legacy)",
- "nma_point_id": "NMA PointID (Legacy)",
- "thing_id": "Thing ID",
- "hydraulic_unit": "HydraulicUnit",
- "hydraulic_unit_type": "HydraulicUnitType",
- "hydraulic_remarks": "Hydraulic Remarks",
- "test_top": "TestTop",
- "test_bottom": "TestBottom",
- "t_ft2_d": "T (ft2/d)",
- "s_dimensionless": "S (dimensionless)",
- "ss_ft_1": "Ss (ft-1)",
- "sy_decimalfractn": "Sy (decimalfractn)",
- "kh_ft_d": "KH (ft/d)",
- "kv_ft_d": "KV (ft/d)",
- "hl_day_1": "HL (day-1)",
- "hd_ft2_d": "HD (ft2/d)",
- "cs_gal_d_ft": "Cs (gal/d/ft)",
- "p_decimal_fraction": "P (decimal fraction)",
- "k_darcy": "k (darcy)",
- "data_source": "Data Source",
- "nma_object_id": "NMA OBJECTID (Legacy)",
- }
-
-
-# ============= EOF =============================================
diff --git a/admin/views/lexicon.py b/admin/views/lexicon.py
deleted file mode 100644
index 57cafa6a5..000000000
--- a/admin/views/lexicon.py
+++ /dev/null
@@ -1,97 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-Lexicon admin views for OcotilloAPI.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class LexiconTermAdmin(OcotilloModelView):
- """
- Admin view for LexiconTerm model.
- """
-
- name = "Lexicon Terms"
- label = "Lexicon Terms"
- icon = "fa fa-book"
- enable_publish_actions = False
-
- sortable_fields = [
- "id",
- "term",
- ]
-
- fields_default_sort = [("term", False)]
-
- searchable_fields = [
- "term",
- "definition",
- ]
-
- fields = [
- "id",
- "term",
- "definition",
- ]
-
- exclude_fields_from_create = [
- "id",
- ]
-
- exclude_fields_from_edit = [
- "id",
- ]
-
-
-class LexiconCategoryAdmin(OcotilloModelView):
- """
- Admin view for LexiconCategory model.
- """
-
- name = "Lexicon Categories"
- label = "Lexicon Categories"
- icon = "fa fa-tags"
- enable_publish_actions = False
-
- sortable_fields = [
- "id",
- "name",
- ]
-
- fields_default_sort = [("name", False)]
-
- searchable_fields = [
- "name",
- "description",
- ]
-
- fields = [
- "id",
- "name",
- "description",
- ]
-
- exclude_fields_from_create = [
- "id",
- ]
-
- exclude_fields_from_edit = [
- "id",
- ]
-
-
-# ============= EOF =============================================
diff --git a/admin/views/location.py b/admin/views/location.py
deleted file mode 100644
index 2ec2f2616..000000000
--- a/admin/views/location.py
+++ /dev/null
@@ -1,122 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-LocationAdmin view for OcotilloAPI.
-
-Provides MS Access-like interface for CRUD operations on Location model.
-"""
-
-from admin.fields import CoordinateHelpField
-from admin.views.base import OcotilloModelView
-
-
-class LocationAdmin(OcotilloModelView):
- """
- Admin view for Location model.
-
- Designed to replicate MS Access "Location Entry Form" and "Location Datasheet View".
-
- Permission Model:
- - Admin: Can create, edit, delete all locations
- - Editor: Can create and edit, cannot delete
- - Viewer: Can only view published locations (read-only)
- """
-
- # ========== Basic Configuration ==========
-
- name = "Locations"
- label = "Locations"
- icon = "fa fa-map-marker"
-
- # ========== List View (MS Access Datasheet View Equivalent) ==========
-
- sortable_fields = [
- "id",
- "description",
- "elevation",
- "county",
- "state",
- "quad_name",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("created_at", True)] # True = descending
-
- searchable_fields = [
- "description",
- "county",
- "state",
- "quad_name",
- "release_status",
- "elevation",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View (MS Access Form View Equivalent) ==========
-
- fields = [
- "id",
- "description",
- CoordinateHelpField(
- "point",
- label="Coordinates (WKT)",
- required=True,
- ),
- "elevation",
- "county",
- "state",
- "quad_name",
- "nma_location_notes",
- "nma_coordinate_notes",
- "nma_data_reliability",
- "release_status",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- "nma_pk_location",
- "nma_date_created",
- "nma_site_date",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- "nma_pk_location",
- "nma_date_created",
- "nma_site_date",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "nma_pk_location",
- "nma_date_created",
- "nma_site_date",
- ]
-
- # ========== Field Labels and Help Text ==========
diff --git a/admin/views/major_chemistry.py b/admin/views/major_chemistry.py
deleted file mode 100644
index 9578f60d1..000000000
--- a/admin/views/major_chemistry.py
+++ /dev/null
@@ -1,169 +0,0 @@
-# ===============================================================================
-# Copyright 2026
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-MajorChemistryAdmin view for legacy NMA_MajorChemistry.
-
-Updated for Integer PK schema:
-- id: Integer PK (autoincrement)
-- nma_global_id: Legacy UUID PK (GlobalID), UNIQUE for audit
-- chemistry_sample_info_id: Integer FK to NMA_Chemistry_SampleInfo.id
-- nma_sample_pt_id: Legacy UUID FK (SamplePtID) for audit
-- nma_sample_point_id: Legacy SamplePointID string
-- nma_object_id: Legacy OBJECTID
-- nma_wclab_id: Legacy WCLab_ID
-"""
-
-from starlette.requests import Request
-from starlette_admin.fields import HasOne
-
-from admin.views.base import OcotilloModelView
-
-
-class MajorChemistryAdmin(OcotilloModelView):
- """
- Admin view for NMA_MajorChemistry model.
- """
-
- # ========== Basic Configuration ==========
-
- identity = "n-m-a_-major-chemistry"
- name = "NMA Major Chemistry"
- label = "NMA Major Chemistry"
- icon = "fa fa-flask"
-
- # Integer PK
- pk_attr = "id"
- pk_type = int
-
- def can_create(self, request: Request) -> bool:
- return False
-
- def can_edit(self, request: Request) -> bool:
- return False
-
- def can_delete(self, request: Request) -> bool:
- return False
-
- # ========== List View ==========
-
- list_fields = [
- "id",
- "nma_global_id",
- "chemistry_sample_info_id",
- "nma_sample_pt_id",
- "nma_sample_point_id",
- HasOne("chemistry_sample_info", identity="n-m-a_-chemistry_-sample-info"),
- "analyte",
- "symbol",
- "sample_value",
- "units",
- "uncertainty",
- "analysis_method",
- "analysis_date",
- "notes",
- "volume",
- "volume_unit",
- "nma_object_id",
- "analyses_agency",
- "nma_wclab_id",
- ]
-
- sortable_fields = [
- "id",
- "nma_global_id",
- "chemistry_sample_info_id",
- "nma_sample_pt_id",
- "nma_sample_point_id",
- "analyte",
- "symbol",
- "sample_value",
- "units",
- "uncertainty",
- "analysis_method",
- "analysis_date",
- "notes",
- "volume",
- "volume_unit",
- "nma_object_id",
- "analyses_agency",
- "nma_wclab_id",
- ]
-
- fields_default_sort = [("analysis_date", True)]
-
- searchable_fields = [
- "nma_global_id",
- "nma_sample_pt_id",
- "nma_sample_point_id",
- "analyte",
- "symbol",
- "analysis_method",
- "notes",
- "analyses_agency",
- "nma_wclab_id",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "nma_global_id",
- "chemistry_sample_info_id",
- "nma_sample_pt_id",
- "nma_sample_point_id",
- HasOne("chemistry_sample_info", identity="n-m-a_-chemistry_-sample-info"),
- "analyte",
- "symbol",
- "sample_value",
- "units",
- "uncertainty",
- "analysis_method",
- "analysis_date",
- "notes",
- "volume",
- "volume_unit",
- "nma_object_id",
- "analyses_agency",
- "nma_wclab_id",
- ]
-
- field_labels = {
- "id": "ID",
- "nma_global_id": "NMA GlobalID (Legacy)",
- "chemistry_sample_info_id": "Chemistry Sample Info ID",
- "nma_sample_pt_id": "NMA SamplePtID (Legacy)",
- "nma_sample_point_id": "NMA SamplePointID (Legacy)",
- "chemistry_sample_info": "Chemistry Sample Info",
- "analyte": "Analyte",
- "symbol": "Symbol",
- "sample_value": "Sample Value",
- "units": "Units",
- "uncertainty": "Uncertainty",
- "analysis_method": "Analysis Method",
- "analysis_date": "Analysis Date",
- "notes": "Notes",
- "volume": "Volume",
- "volume_unit": "Volume Unit",
- "nma_object_id": "NMA OBJECTID (Legacy)",
- "analyses_agency": "Analyses Agency",
- "nma_wclab_id": "NMA WCLab_ID (Legacy)",
- }
-
-
-# ============= EOF =============================================
diff --git a/admin/views/minor_trace_chemistry.py b/admin/views/minor_trace_chemistry.py
deleted file mode 100644
index 0c51e609e..000000000
--- a/admin/views/minor_trace_chemistry.py
+++ /dev/null
@@ -1,138 +0,0 @@
-# ===============================================================================
-# Copyright 2026
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-MinorTraceChemistryAdmin view for legacy NMA_MinorTraceChemistry.
-
-Updated for Integer PK schema:
-- id: Integer PK (autoincrement)
-- nma_global_id: Legacy UUID PK (GlobalID), UNIQUE for audit
-- chemistry_sample_info_id: Integer FK to NMA_Chemistry_SampleInfo.id
-- nma_chemistry_sample_info_uuid: Legacy UUID FK for audit
-"""
-
-from starlette.requests import Request
-from starlette_admin.fields import HasOne
-
-from admin.views.base import OcotilloModelView
-
-
-class MinorTraceChemistryAdmin(OcotilloModelView):
- """
- Admin view for NMA_MinorTraceChemistry model.
- """
-
- # ========== Basic Configuration ==========
-
- identity = "n-m-a_-minor-trace-chemistry"
- name = "Minor Trace Chemistry"
- label = "Minor Trace Chemistry"
- icon = "fa fa-flask"
-
- # Integer PK
- pk_attr = "id"
- pk_type = int
-
- def can_create(self, request: Request) -> bool:
- return False
-
- def can_edit(self, request: Request) -> bool:
- return False
-
- def can_delete(self, request: Request) -> bool:
- return False
-
- # ========== List View ==========
-
- list_fields = [
- "id",
- "nma_global_id",
- HasOne("chemistry_sample_info", identity="n-m-a_-chemistry_-sample-info"),
- "nma_chemistry_sample_info_uuid",
- "analyte",
- "sample_value",
- "units",
- "symbol",
- "analysis_date",
- "analyses_agency",
- ]
-
- sortable_fields = [
- "id",
- "nma_global_id",
- "chemistry_sample_info_id",
- "analyte",
- "sample_value",
- "units",
- "symbol",
- "analysis_date",
- "analyses_agency",
- ]
-
- fields_default_sort = [("analysis_date", True)]
-
- searchable_fields = [
- "nma_global_id",
- "analyte",
- "symbol",
- "analysis_method",
- "notes",
- "analyses_agency",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "nma_global_id",
- HasOne("chemistry_sample_info", identity="n-m-a_-chemistry_-sample-info"),
- "nma_chemistry_sample_info_uuid",
- "analyte",
- "symbol",
- "sample_value",
- "units",
- "uncertainty",
- "analysis_method",
- "analysis_date",
- "notes",
- "volume",
- "volume_unit",
- "analyses_agency",
- ]
-
- field_labels = {
- "id": "ID",
- "nma_global_id": "NMA GlobalID (Legacy)",
- "chemistry_sample_info": "Chemistry Sample Info",
- "chemistry_sample_info_id": "Chemistry Sample Info ID",
- "nma_chemistry_sample_info_uuid": "NMA Chemistry Sample Info UUID (Legacy)",
- "analyte": "Analyte",
- "symbol": "Symbol",
- "sample_value": "Sample Value",
- "units": "Units",
- "uncertainty": "Uncertainty",
- "analysis_method": "Analysis Method",
- "analysis_date": "Analysis Date",
- "notes": "Notes",
- "volume": "Volume",
- "volume_unit": "Volume Unit",
- "analyses_agency": "Analyses Agency",
- }
-
-
-# ============= EOF =============================================
diff --git a/admin/views/notes.py b/admin/views/notes.py
deleted file mode 100644
index 6be42f912..000000000
--- a/admin/views/notes.py
+++ /dev/null
@@ -1,91 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-NotesAdmin view for OcotilloAPI.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class NotesAdmin(OcotilloModelView):
- """
- Admin view for Notes model.
- """
-
- # ========== Basic Configuration ==========
-
- name = "Notes"
- label = "Notes"
- icon = "fa fa-sticky-note"
-
- # ========== List View ==========
-
- sortable_fields = [
- "id",
- "target_table",
- "target_id",
- "note_type",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("created_at", True)]
-
- searchable_fields = [
- "target_table",
- "note_type",
- "content",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "target_table",
- "target_id",
- "note_type",
- "content",
- "release_status",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- ]
-
-
-# ============= EOF =============================================
diff --git a/admin/views/observation.py b/admin/views/observation.py
deleted file mode 100644
index d2e206e36..000000000
--- a/admin/views/observation.py
+++ /dev/null
@@ -1,128 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-ObservationAdmin view for OcotilloAPI.
-
-Provides MS Access-like interface for CRUD operations on Observation (Water Levels) model.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class ObservationAdmin(OcotilloModelView):
- """
- Admin view for Observation model (Water Levels).
-
- Designed to replicate MS Access "Water Level Entry Form" and "Water Level Datasheet View".
-
- Permission Model:
- - Admin: Can create, edit, delete all observations
- - Editor: Can create and edit, cannot delete
- - Viewer: Can only view published observations (read-only)
- """
-
- # ========== Basic Configuration ==========
-
- name = "Observations"
- label = "Observations (Water Levels)"
- icon = "fa fa-line-chart"
-
- # ========== List View (MS Access Datasheet View Equivalent) ==========
-
- sortable_fields = [
- "id",
- "observation_datetime",
- "value",
- "unit",
- "measuring_point_height",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [
- ("observation_datetime", True)
- ] # True = descending (newest first)
-
- searchable_fields = [
- "groundwater_level_reason",
- "notes",
- "observation_datetime",
- "unit",
- "groundwater_level_reason",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200, 500]
-
- # ========== Form View (MS Access Form View Equivalent) ==========
-
- fields = [
- "id",
- # Core measurement data
- "observation_datetime",
- "value",
- "unit",
- "measuring_point_height",
- "groundwater_level_reason",
- "notes",
- # Relationships (display as selects)
- "sample_id",
- "sensor_id",
- "parameter_id",
- "analysis_method_id",
- # Release Status
- "release_status",
- # Audit Fields
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- # Legacy Migration Fields
- "nma_pk_waterlevels",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- "nma_pk_waterlevels",
- # Exclude relationship objects (use IDs instead)
- "sample",
- "sensor",
- "parameter",
- "analysis_method",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "nma_pk_waterlevels",
- # Exclude relationship objects (use IDs instead)
- "sample",
- "sensor",
- "parameter",
- "analysis_method",
- ]
-
- # ========== Field Labels and Help Text ==========
diff --git a/admin/views/parameter.py b/admin/views/parameter.py
deleted file mode 100644
index 50eb674a8..000000000
--- a/admin/views/parameter.py
+++ /dev/null
@@ -1,90 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-ParameterAdmin view for OcotilloAPI.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class ParameterAdmin(OcotilloModelView):
- """
- Admin view for Parameter model.
- """
-
- name = "Parameters"
- label = "Parameters"
- icon = "fa fa-flask"
-
- sortable_fields = [
- "id",
- "parameter_name",
- "matrix",
- "parameter_type",
- "cas_number",
- "default_unit",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("parameter_name", False)]
-
- searchable_fields = [
- "parameter_name",
- "cas_number",
- "matrix",
- "parameter_type",
- "default_unit",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- fields = [
- "id",
- "parameter_name",
- "matrix",
- "parameter_type",
- "cas_number",
- "default_unit",
- "release_status",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- ]
-
-
-# ============= EOF =============================================
diff --git a/admin/views/radionuclides.py b/admin/views/radionuclides.py
deleted file mode 100644
index 27c240aea..000000000
--- a/admin/views/radionuclides.py
+++ /dev/null
@@ -1,165 +0,0 @@
-# ===============================================================================
-# Copyright 2026
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-RadionuclidesAdmin view for legacy NMA_Radionuclides.
-
-Updated for Integer PK schema:
-- id: Integer PK (autoincrement)
-- nma_global_id: Legacy UUID PK (GlobalID), UNIQUE for audit
-- chemistry_sample_info_id: Integer FK to NMA_Chemistry_SampleInfo.id
-- nma_sample_pt_id: Legacy UUID FK (SamplePtID) for audit
-- nma_sample_point_id: Legacy SamplePointID string
-- nma_object_id: Legacy OBJECTID, UNIQUE
-- nma_wclab_id: Legacy WCLab_ID
-"""
-
-from starlette.requests import Request
-
-from admin.views.base import OcotilloModelView
-
-
-class RadionuclidesAdmin(OcotilloModelView):
- """
- Admin view for NMA_Radionuclides model.
- """
-
- # ========== Basic Configuration ==========
-
- name = "NMA Radionuclides"
- label = "NMA Radionuclides"
- icon = "fa fa-radiation"
-
- # Integer PK
- pk_attr = "id"
- pk_type = int
-
- def can_create(self, request: Request) -> bool:
- return False
-
- def can_edit(self, request: Request) -> bool:
- return False
-
- def can_delete(self, request: Request) -> bool:
- return False
-
- # ========== List View ==========
-
- list_fields = [
- "id",
- "nma_global_id",
- "chemistry_sample_info_id",
- "nma_sample_pt_id",
- "nma_sample_point_id",
- "analyte",
- "symbol",
- "sample_value",
- "units",
- "uncertainty",
- "analysis_method",
- "analysis_date",
- "notes",
- "volume",
- "volume_unit",
- "nma_object_id",
- "analyses_agency",
- "nma_wclab_id",
- ]
-
- sortable_fields = [
- "id",
- "nma_global_id",
- "chemistry_sample_info_id",
- "nma_sample_pt_id",
- "nma_sample_point_id",
- "analyte",
- "symbol",
- "sample_value",
- "units",
- "uncertainty",
- "analysis_method",
- "analysis_date",
- "notes",
- "volume",
- "volume_unit",
- "nma_object_id",
- "analyses_agency",
- "nma_wclab_id",
- ]
-
- fields_default_sort = [("analysis_date", True)]
-
- searchable_fields = [
- "nma_global_id",
- "nma_sample_pt_id",
- "nma_sample_point_id",
- "analyte",
- "symbol",
- "analysis_method",
- "analysis_date",
- "notes",
- "analyses_agency",
- "nma_wclab_id",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "nma_global_id",
- "chemistry_sample_info_id",
- "nma_sample_pt_id",
- "nma_sample_point_id",
- "analyte",
- "symbol",
- "sample_value",
- "units",
- "uncertainty",
- "analysis_method",
- "analysis_date",
- "notes",
- "volume",
- "volume_unit",
- "nma_object_id",
- "analyses_agency",
- "nma_wclab_id",
- ]
-
- field_labels = {
- "id": "ID",
- "nma_global_id": "NMA GlobalID (Legacy)",
- "chemistry_sample_info_id": "Chemistry Sample Info ID",
- "nma_sample_pt_id": "NMA SamplePtID (Legacy)",
- "nma_sample_point_id": "NMA SamplePointID (Legacy)",
- "analyte": "Analyte",
- "symbol": "Symbol",
- "sample_value": "Sample Value",
- "units": "Units",
- "uncertainty": "Uncertainty",
- "analysis_method": "Analysis Method",
- "analysis_date": "Analysis Date",
- "notes": "Notes",
- "volume": "Volume",
- "volume_unit": "Volume Unit",
- "nma_object_id": "NMA OBJECTID (Legacy)",
- "analyses_agency": "Analyses Agency",
- "nma_wclab_id": "NMA WCLab_ID (Legacy)",
- }
-
-
-# ============= EOF =============================================
diff --git a/admin/views/sample.py b/admin/views/sample.py
deleted file mode 100644
index b5247a913..000000000
--- a/admin/views/sample.py
+++ /dev/null
@@ -1,103 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-SampleAdmin view for OcotilloAPI.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class SampleAdmin(OcotilloModelView):
- """
- Admin view for Sample model.
- """
-
- # ========== Basic Configuration ==========
-
- name = "Samples"
- label = "Samples"
- icon = "fa fa-flask"
-
- # ========== List View ==========
-
- sortable_fields = [
- "id",
- "sample_name",
- "sample_date",
- "sample_matrix",
- "sample_method",
- "qc_type",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("sample_date", True)]
-
- searchable_fields = [
- "sample_name",
- "notes",
- "nma_pk_waterlevels",
- "sample_matrix",
- "sample_method",
- "qc_type",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "field_activity_id",
- "field_event_participant_id",
- "sample_date",
- "sample_name",
- "sample_matrix",
- "sample_method",
- "qc_type",
- "depth_top",
- "depth_bottom",
- "notes",
- "nma_pk_waterlevels",
- "release_status",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- ]
-
-
-# ============= EOF =============================================
diff --git a/admin/views/sensor.py b/admin/views/sensor.py
deleted file mode 100644
index 28d41e44e..000000000
--- a/admin/views/sensor.py
+++ /dev/null
@@ -1,123 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-SensorAdmin view for OcotilloAPI.
-
-Provides MS Access-like interface for CRUD operations on Sensor (Equipment) model.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class SensorAdmin(OcotilloModelView):
- """
- Admin view for Sensor model (Equipment).
-
- Designed to replicate MS Access "Equipment Entry Form" and "Equipment Datasheet View".
-
- Permission Model:
- - Admin: Can create, edit, delete all sensors
- - Editor: Can create and edit, cannot delete
- - Viewer: Can only view published sensors (read-only)
- """
-
- # ========== Basic Configuration ==========
-
- name = "Sensors"
- label = "Sensors (Equipment)"
- icon = "fa fa-microchip"
-
- # ========== List View (MS Access Datasheet View Equivalent) ==========
-
- sortable_fields = [
- "id",
- "name",
- "sensor_type",
- "model",
- "serial_no",
- "owner_agency",
- "sensor_status",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("created_at", True)] # True = descending
-
- searchable_fields = [
- "name",
- "serial_no",
- "model",
- "pcn_number",
- "sensor_type",
- "owner_agency",
- "sensor_status",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View (MS Access Form View Equivalent) ==========
-
- fields = [
- "id",
- # Equipment Information
- "name",
- "sensor_type",
- "model",
- "serial_no",
- "pcn_number",
- "owner_agency",
- "sensor_status",
- "notes",
- # Release Status
- "release_status",
- # Audit Fields
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- # Legacy Migration Fields
- "nma_pk_equipment",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- "nma_pk_equipment",
- # Exclude complex relationships
- "observations",
- "deployments",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "nma_pk_equipment",
- # Exclude complex relationships
- "observations",
- "deployments",
- ]
-
- # ========== Field Labels and Help Text ==========
diff --git a/admin/views/soil_rock_results.py b/admin/views/soil_rock_results.py
deleted file mode 100644
index 947804980..000000000
--- a/admin/views/soil_rock_results.py
+++ /dev/null
@@ -1,77 +0,0 @@
-"""
-SoilRockResultsAdmin view for legacy NMA_Soil_Rock_Results.
-
-Already has Integer PK. Updated for legacy column rename:
-- point_id -> nma_point_id
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class SoilRockResultsAdmin(OcotilloModelView):
- """
- Read-only admin view for SoilRockResults legacy model.
- """
-
- # ========== Basic Configuration ==========
- name = "NMA Soil Rock Results"
- label = "NMA Soil Rock Results"
- icon = "fa fa-mountain"
-
- # Integer PK (already correct)
- pk_attr = "id"
- pk_type = int
-
- # Pagination
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== List View ==========
- list_fields = [
- "id",
- "nma_point_id",
- "sample_type",
- "date_sampled",
- "d13c",
- "d18o",
- "sampled_by",
- "thing_id",
- ]
-
- sortable_fields = [
- "id",
- "nma_point_id",
- ]
-
- searchable_fields = [
- "nma_point_id",
- "sample_type",
- "date_sampled",
- "sampled_by",
- ]
-
- fields_default_sort = [("id", True)]
-
- # ========== Detail View ==========
- fields = [
- "id",
- "nma_point_id",
- "sample_type",
- "date_sampled",
- "d13c",
- "d18o",
- "sampled_by",
- "thing_id",
- ]
-
- # ========== Legacy Field Labels ==========
- field_labels = {
- "id": "ID",
- "nma_point_id": "NMA Point_ID (Legacy)",
- "sample_type": "Sample Type",
- "date_sampled": "Date Sampled",
- "d13c": "d13C",
- "d18o": "d18O",
- "sampled_by": "Sampled by",
- "thing_id": "ThingID",
- }
diff --git a/admin/views/stratigraphy.py b/admin/views/stratigraphy.py
deleted file mode 100644
index 0bbd32231..000000000
--- a/admin/views/stratigraphy.py
+++ /dev/null
@@ -1,100 +0,0 @@
-"""
-StratigraphyAdmin view for legacy stratigraphy.
-
-Updated for Integer PK schema:
-- id: Integer PK (autoincrement)
-- nma_global_id: Legacy UUID PK (GlobalID), UNIQUE for audit
-- nma_well_id: Legacy WellID UUID
-- nma_point_id: Legacy PointID string
-- nma_object_id: Legacy OBJECTID, UNIQUE
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class StratigraphyAdmin(OcotilloModelView):
- """
- Read-only admin view for Stratigraphy legacy model.
- """
-
- # ========== Basic Configuration ==========
- name = "NMA Stratigraphy"
- label = "NMA Stratigraphy"
- icon = "fa fa-layer-group"
-
- # Integer PK
- pk_attr = "id"
- pk_type = int
-
- # Pagination
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== List View ==========
-
- sortable_fields = [
- "id",
- "nma_global_id",
- "nma_object_id",
- "nma_point_id",
- ]
-
- fields_default_sort = [("nma_point_id", False), ("strat_top", False)]
-
- searchable_fields = [
- "nma_point_id",
- "nma_global_id",
- "unit_identifier",
- "lithology",
- "lithologic_modifier",
- "contributing_unit",
- "strat_source",
- "strat_notes",
- ]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "nma_global_id",
- "nma_well_id",
- "nma_point_id",
- "thing_id",
- "strat_top",
- "strat_bottom",
- "unit_identifier",
- "lithology",
- "lithologic_modifier",
- "contributing_unit",
- "strat_source",
- "strat_notes",
- "nma_object_id",
- ]
-
- exclude_fields_from_create = [
- "id",
- "nma_object_id",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "nma_object_id",
- ]
-
- # ========== Legacy Field Labels ==========
- field_labels = {
- "id": "ID",
- "nma_global_id": "NMA GlobalID (Legacy)",
- "nma_well_id": "NMA WellID (Legacy)",
- "nma_point_id": "NMA PointID (Legacy)",
- "thing_id": "ThingID",
- "strat_top": "StratTop",
- "strat_bottom": "StratBottom",
- "unit_identifier": "UnitIdentifier",
- "lithology": "Lithology",
- "lithologic_modifier": "LithologicModifier",
- "contributing_unit": "ContributingUnit",
- "strat_source": "StratSource",
- "strat_notes": "StratNotes",
- "nma_object_id": "NMA OBJECTID (Legacy)",
- }
diff --git a/admin/views/surface_water.py b/admin/views/surface_water.py
deleted file mode 100644
index be6da860d..000000000
--- a/admin/views/surface_water.py
+++ /dev/null
@@ -1,96 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-SurfaceWaterDataAdmin view for OcotilloAPI.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class SurfaceWaterDataAdmin(OcotilloModelView):
- """
- Admin view for SurfaceWaterData legacy model.
- """
-
- name = "NMA Surface Water Data"
- label = "NMA Surface Water Data"
- icon = "fa fa-water"
- enable_publish_actions = False
-
- sortable_fields = [
- "surface_id",
- "point_id",
- "date_measured",
- "discharge",
- "discharge_units",
- "discharge_method",
- "discharge_source",
- "formation_zone",
- "aq_class",
- ]
-
- fields_default_sort = [("date_measured", True)]
-
- searchable_fields = [
- "point_id",
- "discharge",
- "formation_zone",
- "aq_class",
- "data_source",
- "discharge_units",
- "discharge_method",
- "discharge_source",
- "formation_zone",
- "aq_class",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- fields = [
- "surface_id",
- "point_id",
- "object_id",
- "date_measured",
- "discharge",
- "discharge_rate",
- "discharge_units",
- "discharge_method",
- "discharge_source",
- "formation_zone",
- "aq_class",
- "site_notes",
- "field_method_notes",
- "source_notes",
- "data_source",
- ]
-
- # ========== READ ONLY ==========
- enable_publish_actions = (
- False # hides publish/unpublish actions inherited from base
- )
-
- def can_create(self, request) -> bool:
- return False
-
- def can_edit(self, request) -> bool:
- return False
-
- def can_delete(self, request) -> bool:
- return False
-
-
-# ============= EOF =============================================
diff --git a/admin/views/surface_water_photos.py b/admin/views/surface_water_photos.py
deleted file mode 100644
index 2d2b73299..000000000
--- a/admin/views/surface_water_photos.py
+++ /dev/null
@@ -1,71 +0,0 @@
-from admin.views.base import OcotilloModelView
-
-
-class SurfaceWaterPhotosAdmin(OcotilloModelView):
- """
- Admin view for legacy SurfaceWaterPhotos model (NMA_SurfaceWaterPhotos).
- """
-
- # ========== Basic Configuration ==========
- name = "NMA Surface Water Photos"
- label = "NMA Surface Water Photos"
- icon = "fa fa-water"
-
- # Pagination
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== List View ==========
- list_fields = [
- "surface_id",
- "point_id",
- "ole_path",
- "object_id",
- "global_id",
- ]
-
- sortable_fields = [
- "global_id",
- "object_id",
- "point_id",
- ]
-
- fields_default_sort = [("point_id", False), ("object_id", False)]
-
- searchable_fields = [
- "point_id",
- "global_id",
- "ole_path",
- ]
-
- # ========== Detail View ==========
- fields = [
- "surface_id",
- "point_id",
- "ole_path",
- "object_id",
- "global_id",
- ]
-
- # ========== Legacy Field Labels ==========
- field_labels = {
- "surface_id": "SurfaceID",
- "point_id": "PointID",
- "ole_path": "OLEPath",
- "object_id": "OBJECTID",
- "global_id": "GlobalID",
- }
-
- # ========== READ ONLY ==========
- enable_publish_actions = (
- False # hides publish/unpublish actions inherited from base
- )
-
- def can_create(self, request) -> bool:
- return False
-
- def can_edit(self, request) -> bool:
- return False
-
- def can_delete(self, request) -> bool:
- return False
diff --git a/admin/views/thing.py b/admin/views/thing.py
deleted file mode 100644
index da6d7acbb..000000000
--- a/admin/views/thing.py
+++ /dev/null
@@ -1,161 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-ThingAdmin view for OcotilloAPI.
-
-Provides MS Access-like interface for CRUD operations on Thing (Wells/Springs) model.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class ThingAdmin(OcotilloModelView):
- """
- Admin view for Thing model (Wells, Springs, etc.).
-
- Designed to replicate MS Access "Well Data Entry Form" and "Well Datasheet View".
-
- Permission Model:
- - Admin: Can create, edit, delete all things
- - Editor: Can create and edit, cannot delete
- - Viewer: Can only view published things (read-only)
- """
-
- # ========== Basic Configuration ==========
-
- identity = "thing"
- name = "Things"
- label = "Things (Wells/Springs)"
- icon = "fa fa-tint"
-
- # ========== List View (MS Access Datasheet View Equivalent) ==========
-
- sortable_fields = [
- "id",
- "name",
- "thing_type",
- "well_depth",
- "hole_depth",
- "first_visit_date",
- "release_status",
- "created_at",
- ]
-
- fields_default_sort = [("created_at", True)] # True = descending
-
- searchable_fields = [
- "name",
- "thing_type",
- "well_driller_name",
- "well_depth",
- "first_visit_date",
- "release_status",
- "created_at",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View (MS Access Form View Equivalent) ==========
-
- fields = [
- "id",
- # Basic Information
- "name",
- "thing_type",
- "first_visit_date",
- # Well Construction
- "well_depth",
- "hole_depth",
- "well_casing_diameter",
- "well_casing_depth",
- "well_completion_date",
- "well_driller_name",
- "well_construction_method",
- "well_pump_type",
- "well_pump_depth",
- "formation_completion_code",
- # Spring-specific
- "spring_type",
- # Release Status
- "release_status",
- # Audit Fields
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- # Legacy Migration Fields
- "nma_pk_welldata",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- "nma_pk_welldata",
- # Exclude complex relationships from create form
- "location_associations",
- "contact_associations",
- "asset_associations",
- "field_events",
- "deployments",
- "group_associations",
- "screens",
- "well_purposes",
- "well_casing_materials",
- "links",
- "measuring_points",
- "monitoring_frequencies",
- "aquifer_associations",
- "formation_associations",
- "status_history",
- "permission_history",
- "data_provenance",
- "notes",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "nma_pk_welldata",
- # Exclude complex relationships from edit form (manage separately)
- "location_associations",
- "contact_associations",
- "asset_associations",
- "field_events",
- "deployments",
- "group_associations",
- "screens",
- "well_purposes",
- "well_casing_materials",
- "links",
- "measuring_points",
- "monitoring_frequencies",
- "aquifer_associations",
- "formation_associations",
- "status_history",
- "permission_history",
- "data_provenance",
- "notes",
- ]
-
- # ========== Field Labels and Help Text ==========
diff --git a/admin/views/transducer_observation.py b/admin/views/transducer_observation.py
deleted file mode 100644
index d9318d0e8..000000000
--- a/admin/views/transducer_observation.py
+++ /dev/null
@@ -1,205 +0,0 @@
-# ===============================================================================
-# Copyright 2026
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-TransducerObservationAdmin view for transducer observations.
-"""
-
-from admin.views.base import OcotilloModelView
-
-
-class TransducerObservationAdmin(OcotilloModelView):
- """
- Admin view for TransducerObservation model.
- """
-
- # ========== Basic Configuration ==========
-
- name = "Transducer Observations"
- label = "Transducer Observations"
- icon = "fa fa-tachometer-alt"
-
- # ========== List View ==========
-
- sortable_fields = [
- "id",
- "observation_datetime",
- "value",
- "parameter_id",
- "deployment_id",
- "release_status",
- ]
-
- fields_default_sort = [("observation_datetime", True)]
-
- searchable_fields = [
- "observation_datetime",
- "parameter_id",
- "deployment_id",
- "release_status",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Form View ==========
-
- fields = [
- "id",
- "observation_datetime",
- "value",
- "parameter_id",
- "deployment_id",
- "release_status",
- "nma_waterlevelscontinuous_pressure_conddl_ms_cm",
- "nma_waterlevelscontinuous_pressure_checked_by",
- "nma_waterlevelscontinuous_pressure_created",
- "nma_waterlevelscontinuous_pressure_data_source",
- "nma_waterlevelscontinuous_pressure_global_id",
- "nma_waterlevelscontinuous_pressure_measurement_method",
- "nma_waterlevelscontinuous_pressure_measuring_agency",
- "nma_waterlevelscontinuous_pressure_notes",
- "nma_waterlevelscontinuous_pressure_processed_by",
- "nma_waterlevelscontinuous_pressure_qced",
- "nma_waterlevelscontinuous_pressure_temperature_water",
- "nma_waterlevelscontinuous_pressure_updated",
- "nma_waterlevelscontinuous_pressure_water_head",
- "nma_waterlevelscontinuous_pressure_water_head_adjusted",
- "nma_waterlevelscontinuous_acoustic_created",
- "nma_waterlevelscontinuous_acoustic_data_source",
- "nma_waterlevelscontinuous_acoustic_global_id",
- "nma_waterlevelscontinuous_acoustic_measurement_method",
- "nma_waterlevelscontinuous_acoustic_measuring_agency",
- "nma_waterlevelscontinuous_acoustic_notes",
- "nma_waterlevelscontinuous_acoustic_point_id",
- "nma_waterlevelscontinuous_acoustic_pre_process_data_field",
- "nma_waterlevelscontinuous_acoustic_public_release",
- "nma_waterlevelscontinuous_acoustic_sensor_hgt_above_mp",
- "nma_waterlevelscontinuous_acoustic_serial_no",
- "nma_waterlevelscontinuous_acoustic_server_receipt_date",
- "nma_waterlevelscontinuous_acoustic_speaker_to_mic_length",
- "nma_waterlevelscontinuous_acoustic_temperature_air",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- ]
-
- exclude_fields_from_create = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "updated_by_id",
- "updated_by_name",
- "nma_waterlevelscontinuous_pressure_conddl_ms_cm",
- "nma_waterlevelscontinuous_pressure_checked_by",
- "nma_waterlevelscontinuous_pressure_created",
- "nma_waterlevelscontinuous_pressure_data_source",
- "nma_waterlevelscontinuous_pressure_global_id",
- "nma_waterlevelscontinuous_pressure_measurement_method",
- "nma_waterlevelscontinuous_pressure_measuring_agency",
- "nma_waterlevelscontinuous_pressure_notes",
- "nma_waterlevelscontinuous_pressure_processed_by",
- "nma_waterlevelscontinuous_pressure_qced",
- "nma_waterlevelscontinuous_pressure_temperature_water",
- "nma_waterlevelscontinuous_pressure_updated",
- "nma_waterlevelscontinuous_pressure_water_head",
- "nma_waterlevelscontinuous_pressure_water_head_adjusted",
- "nma_waterlevelscontinuous_acoustic_created",
- "nma_waterlevelscontinuous_acoustic_data_source",
- "nma_waterlevelscontinuous_acoustic_global_id",
- "nma_waterlevelscontinuous_acoustic_measurement_method",
- "nma_waterlevelscontinuous_acoustic_measuring_agency",
- "nma_waterlevelscontinuous_acoustic_notes",
- "nma_waterlevelscontinuous_acoustic_point_id",
- "nma_waterlevelscontinuous_acoustic_pre_process_data_field",
- "nma_waterlevelscontinuous_acoustic_public_release",
- "nma_waterlevelscontinuous_acoustic_sensor_hgt_above_mp",
- "nma_waterlevelscontinuous_acoustic_serial_no",
- "nma_waterlevelscontinuous_acoustic_server_receipt_date",
- "nma_waterlevelscontinuous_acoustic_speaker_to_mic_length",
- "nma_waterlevelscontinuous_acoustic_temperature_air",
- ]
-
- exclude_fields_from_edit = [
- "id",
- "created_at",
- "created_by_id",
- "created_by_name",
- "nma_waterlevelscontinuous_pressure_conddl_ms_cm",
- "nma_waterlevelscontinuous_pressure_checked_by",
- "nma_waterlevelscontinuous_pressure_created",
- "nma_waterlevelscontinuous_pressure_data_source",
- "nma_waterlevelscontinuous_pressure_global_id",
- "nma_waterlevelscontinuous_pressure_measurement_method",
- "nma_waterlevelscontinuous_pressure_measuring_agency",
- "nma_waterlevelscontinuous_pressure_notes",
- "nma_waterlevelscontinuous_pressure_processed_by",
- "nma_waterlevelscontinuous_pressure_qced",
- "nma_waterlevelscontinuous_pressure_temperature_water",
- "nma_waterlevelscontinuous_pressure_updated",
- "nma_waterlevelscontinuous_pressure_water_head",
- "nma_waterlevelscontinuous_pressure_water_head_adjusted",
- "nma_waterlevelscontinuous_acoustic_created",
- "nma_waterlevelscontinuous_acoustic_data_source",
- "nma_waterlevelscontinuous_acoustic_global_id",
- "nma_waterlevelscontinuous_acoustic_measurement_method",
- "nma_waterlevelscontinuous_acoustic_measuring_agency",
- "nma_waterlevelscontinuous_acoustic_notes",
- "nma_waterlevelscontinuous_acoustic_point_id",
- "nma_waterlevelscontinuous_acoustic_pre_process_data_field",
- "nma_waterlevelscontinuous_acoustic_public_release",
- "nma_waterlevelscontinuous_acoustic_sensor_hgt_above_mp",
- "nma_waterlevelscontinuous_acoustic_serial_no",
- "nma_waterlevelscontinuous_acoustic_server_receipt_date",
- "nma_waterlevelscontinuous_acoustic_speaker_to_mic_length",
- "nma_waterlevelscontinuous_acoustic_temperature_air",
- ]
-
- readonly_fields = [
- "nma_waterlevelscontinuous_pressure_conddl_ms_cm",
- "nma_waterlevelscontinuous_pressure_checked_by",
- "nma_waterlevelscontinuous_pressure_created",
- "nma_waterlevelscontinuous_pressure_data_source",
- "nma_waterlevelscontinuous_pressure_global_id",
- "nma_waterlevelscontinuous_pressure_measurement_method",
- "nma_waterlevelscontinuous_pressure_measuring_agency",
- "nma_waterlevelscontinuous_pressure_notes",
- "nma_waterlevelscontinuous_pressure_processed_by",
- "nma_waterlevelscontinuous_pressure_qced",
- "nma_waterlevelscontinuous_pressure_temperature_water",
- "nma_waterlevelscontinuous_pressure_updated",
- "nma_waterlevelscontinuous_pressure_water_head",
- "nma_waterlevelscontinuous_pressure_water_head_adjusted",
- "nma_waterlevelscontinuous_acoustic_created",
- "nma_waterlevelscontinuous_acoustic_data_source",
- "nma_waterlevelscontinuous_acoustic_global_id",
- "nma_waterlevelscontinuous_acoustic_measurement_method",
- "nma_waterlevelscontinuous_acoustic_measuring_agency",
- "nma_waterlevelscontinuous_acoustic_notes",
- "nma_waterlevelscontinuous_acoustic_point_id",
- "nma_waterlevelscontinuous_acoustic_pre_process_data_field",
- "nma_waterlevelscontinuous_acoustic_public_release",
- "nma_waterlevelscontinuous_acoustic_sensor_hgt_above_mp",
- "nma_waterlevelscontinuous_acoustic_serial_no",
- "nma_waterlevelscontinuous_acoustic_server_receipt_date",
- "nma_waterlevelscontinuous_acoustic_speaker_to_mic_length",
- "nma_waterlevelscontinuous_acoustic_temperature_air",
- ]
-
-
-# ============= EOF =============================================
diff --git a/admin/views/waterlevelscontinuous_pressure_daily.py b/admin/views/waterlevelscontinuous_pressure_daily.py
deleted file mode 100644
index ac2afb020..000000000
--- a/admin/views/waterlevelscontinuous_pressure_daily.py
+++ /dev/null
@@ -1,148 +0,0 @@
-# ===============================================================================
-# Copyright 2026
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-WaterLevelsContinuousPressureDailyAdmin view for legacy NMA_WaterLevelsContinuous_Pressure_Daily.
-"""
-
-from starlette.requests import Request
-
-from admin.views.base import OcotilloModelView
-
-
-class WaterLevelsContinuousPressureDailyAdmin(OcotilloModelView):
- """
- Admin view for NMA_WaterLevelsContinuous_Pressure_Daily model.
- """
-
- # ========== Basic Configuration ==========
- name = "NMA Water Levels Continuous Pressure Daily"
- label = "NMA Water Levels Continuous Pressure Daily"
- icon = "fa fa-tachometer-alt"
-
- def can_create(self, request: Request) -> bool:
- return False
-
- def can_edit(self, request: Request) -> bool:
- return False
-
- def can_delete(self, request: Request) -> bool:
- return False
-
- # ========== List View ==========
- list_fields = [
- "global_id",
- "object_id",
- "well_id",
- "point_id",
- "date_measured",
- "temperature_water",
- "water_head",
- "water_head_adjusted",
- "depth_to_water_bgs",
- "measurement_method",
- "data_source",
- "measuring_agency",
- "qced",
- "notes",
- "created",
- "updated",
- "processed_by",
- "checked_by",
- "cond_dl_ms_cm",
- ]
-
- sortable_fields = [
- "global_id",
- "object_id",
- "well_id",
- "point_id",
- "date_measured",
- "water_head",
- "depth_to_water_bgs",
- "measurement_method",
- "data_source",
- "measuring_agency",
- "qced",
- "created",
- "updated",
- "processed_by",
- "checked_by",
- "cond_dl_ms_cm",
- ]
-
- fields_default_sort = [("date_measured", True)]
-
- searchable_fields = [
- "global_id",
- "well_id",
- "point_id",
- "date_measured",
- "measurement_method",
- "data_source",
- "measuring_agency",
- "notes",
- ]
-
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== Detail View ==========
- fields = [
- "global_id",
- "object_id",
- "well_id",
- "point_id",
- "date_measured",
- "temperature_water",
- "water_head",
- "water_head_adjusted",
- "depth_to_water_bgs",
- "measurement_method",
- "data_source",
- "measuring_agency",
- "qced",
- "notes",
- "created",
- "updated",
- "processed_by",
- "checked_by",
- "cond_dl_ms_cm",
- ]
-
- field_labels = {
- "global_id": "GlobalID",
- "object_id": "OBJECTID",
- "well_id": "WellID",
- "point_id": "PointID",
- "date_measured": "Date Measured",
- "temperature_water": "Temperature Water",
- "water_head": "Water Head",
- "water_head_adjusted": "Water Head Adjusted",
- "depth_to_water_bgs": "Depth To Water (BGS)",
- "measurement_method": "Measurement Method",
- "data_source": "Data Source",
- "measuring_agency": "Measuring Agency",
- "qced": "QCed",
- "notes": "Notes",
- "created": "Created",
- "updated": "Updated",
- "processed_by": "Processed By",
- "checked_by": "Checked By",
- "cond_dl_ms_cm": "CONDDL (mS/cm)",
- }
-
-
-# ============= EOF =============================================
diff --git a/admin/views/weather_data.py b/admin/views/weather_data.py
deleted file mode 100644
index 662721c3a..000000000
--- a/admin/views/weather_data.py
+++ /dev/null
@@ -1,66 +0,0 @@
-from admin.views.base import OcotilloModelView
-
-
-class WeatherDataAdmin(OcotilloModelView):
- """
- Admin view for legacy WeatherData model (NMA_WeatherData).
- """
-
- # ========== Basic Configuration ==========
- name = "NMA Weather Data"
- label = "NMA Weather Data"
- icon = "fa fa-cloud-sun"
-
- # Pagination
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== List View ==========
- list_fields = [
- "location_id",
- "point_id",
- "weather_id",
- "object_id",
- ]
-
- sortable_fields = [
- "object_id",
- "point_id",
- ]
-
- fields_default_sort = [("point_id", False), ("object_id", False)]
-
- searchable_fields = [
- "point_id",
- "weather_id",
- ]
-
- # ========== Detail View ==========
- fields = [
- "location_id",
- "point_id",
- "weather_id",
- "object_id",
- ]
-
- # ========== Legacy Field Labels ==========
- field_labels = {
- "location_id": "LocationId",
- "point_id": "PointID",
- "weather_id": "WeatherID",
- "object_id": "OBJECTID",
- }
-
- # ========== READ ONLY ==========
- enable_publish_actions = (
- False # hides publish/unpublish actions inherited from base
- )
-
- def can_create(self, request) -> bool:
- return False
-
- def can_edit(self, request) -> bool:
- return False
-
- def can_delete(self, request) -> bool:
- return False
diff --git a/admin/views/weather_photos.py b/admin/views/weather_photos.py
deleted file mode 100644
index 006d1b10a..000000000
--- a/admin/views/weather_photos.py
+++ /dev/null
@@ -1,70 +0,0 @@
-from admin.views.base import OcotilloModelView
-
-
-class WeatherPhotosAdmin(OcotilloModelView):
- """
- Admin view for legacy WeatherPhotos model (NMA_WeatherPhotos).
- """
-
- # ========== Basic Configuration ==========
- name = "NMA Weather Photos"
- label = "NMA Weather Photos"
- icon = "fa fa-cloud"
-
- # Pagination
- page_size = 50
- page_size_options = [25, 50, 100, 200]
-
- # ========== List View ==========
- list_fields = [
- "weather_id",
- "point_id",
- "ole_path",
- "object_id",
- "global_id",
- ]
-
- sortable_fields = [
- "global_id",
- "object_id",
- "point_id",
- ]
-
- fields_default_sort = [("point_id", False), ("object_id", False)]
-
- searchable_fields = [
- "point_id",
- "ole_path",
- ]
-
- # ========== Detail View ==========
- fields = [
- "weather_id",
- "point_id",
- "ole_path",
- "object_id",
- "global_id",
- ]
-
- # ========== Legacy Field Labels ==========
- field_labels = {
- "weather_id": "WeatherID",
- "point_id": "PointID",
- "ole_path": "OLEPath",
- "object_id": "OBJECTID",
- "global_id": "GlobalID",
- }
-
- # ========== READ ONLY ==========
- enable_publish_actions = (
- False # hides publish/unpublish actions inherited from base
- )
-
- def can_create(self, request) -> bool:
- return False
-
- def can_edit(self, request) -> bool:
- return False
-
- def can_delete(self, request) -> bool:
- return False
diff --git a/core/factory.py b/core/factory.py
index 69bcfba7e..3877e7bf3 100644
--- a/core/factory.py
+++ b/core/factory.py
@@ -6,8 +6,6 @@
from core.initializers import (
configure_apitally_middleware,
configure_cors_middleware,
- configure_lazy_admin,
- configure_session_middleware,
register_api_routes,
)
@@ -47,9 +45,6 @@ def create_api_app():
from core.pygeoapi import mount_pygeoapi
mount_pygeoapi(app)
- if os.environ.get("SESSION_SECRET_KEY"):
- configure_session_middleware(app)
configure_cors_middleware(app)
configure_apitally_middleware(app)
- configure_lazy_admin(app)
return app
diff --git a/core/initializers.py b/core/initializers.py
index 356005d80..845d831dc 100644
--- a/core/initializers.py
+++ b/core/initializers.py
@@ -13,7 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
-import asyncio
import os
from pathlib import Path
@@ -21,7 +20,6 @@
from sqlalchemy import text, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import DatabaseError
-from starlette.responses import PlainTextResponse
from db import Base
from db.engine import session_ctx
@@ -237,17 +235,6 @@ def register_api_routes(app):
app.state.api_routes_registered = True
-def configure_session_middleware(app):
- 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
-
-
def configure_cors_middleware(app):
from starlette.middleware.cors import CORSMiddleware
@@ -284,43 +271,8 @@ def configure_apitally_middleware(app):
def configure_middleware(app):
- configure_session_middleware(app)
configure_cors_middleware(app)
configure_apitally_middleware(app)
-def configure_admin(app):
- if getattr(app.state, "admin_configured", False):
- return
-
- from admin import create_admin
- from admin.auth_routes import router as admin_auth_router
-
- app.include_router(admin_auth_router)
- create_admin(app)
- app.state.admin_configured = True
-
-
-def configure_lazy_admin(app):
- if getattr(app.state, "lazy_admin_configured", False):
- return
-
- app.state.admin_configure_lock = asyncio.Lock()
-
- @app.middleware("http")
- async def ensure_admin_initialized(request, call_next):
- if request.url.path.startswith("/admin"):
- if not getattr(app.state, "session_middleware_configured", False):
- return PlainTextResponse(
- "Admin requires SESSION_SECRET_KEY to be configured.",
- status_code=503,
- )
- async with app.state.admin_configure_lock:
- if not getattr(app.state, "admin_configured", False):
- configure_admin(app)
- return await call_next(request)
-
- app.state.lazy_admin_configured = True
-
-
# ============= EOF =============================================
diff --git a/docker-compose.yml b/docker-compose.yml
index 94991fb99..3cfdaffe6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -36,7 +36,6 @@ services:
- POSTGRES_PORT=5432
- MODE=${MODE}
- AUTHENTIK_DISABLE_AUTHENTICATION=${AUTHENTIK_DISABLE_AUTHENTICATION}
- - SESSION_SECRET_KEY=${SESSION_SECRET_KEY}
- PYGEOAPI_POSTGRES_HOST=db
- PYGEOAPI_POSTGRES_PORT=5432
- PYGEOAPI_POSTGRES_DB=ocotilloapi_dev
diff --git a/pyproject.toml b/pyproject.toml
index 481a389ef..a8f7305f3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -48,7 +48,6 @@ dependencies = [
"httpx==0.28.1",
"idna==3.18",
"iniconfig==2.3.0",
- "itsdangerous>=2.2.0",
"jinja2==3.1.6",
"mako==1.3.12",
"markupsafe==3.0.3",
@@ -92,7 +91,6 @@ dependencies = [
"sqlalchemy-utils==0.42.1",
"sqlparse>=0.5.5",
"starlette==1.3.1",
- "starlette-admin[i18n]==0.17.1",
"typer==0.27.0",
"typing-extensions==4.16.0",
"typing-inspection==0.4.2",
@@ -152,12 +150,6 @@ cli = [
"google-api-python-client==2.198.0",
]
-[tool.pytest.ini_options]
-filterwarnings = [
- "ignore:'HTTP_422_UNPROCESSABLE_ENTITY' is deprecated. Use 'HTTP_422_UNPROCESSABLE_CONTENT' instead.:DeprecationWarning:starlette_admin.*",
-]
-
-
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
diff --git a/tests/__init__.py b/tests/__init__.py
index 57fa0c351..3f5666036 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -42,8 +42,6 @@ def _normalize_test_db_host() -> None:
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
diff --git a/tests/conftest.py b/tests/conftest.py
index 9eb1afd15..f77d8fa3b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -29,8 +29,6 @@ def pytest_configure():
except OSError:
os.environ[env_name] = "localhost"
os.environ.setdefault("POSTGRES_PORT", "54321")
- # NOTE: This hardcoded secret key is for tests only and must NEVER be used in production.
- os.environ.setdefault("SESSION_SECRET_KEY", "test-session-secret-key")
# Always use test database, never dev
os.environ["POSTGRES_DB"] = "ocotilloapi_test"
diff --git a/tests/features/admin-minor-trace-chemistry.feature b/tests/features/admin-minor-trace-chemistry.feature
deleted file mode 100644
index b8c035b5c..000000000
--- a/tests/features/admin-minor-trace-chemistry.feature
+++ /dev/null
@@ -1,45 +0,0 @@
-@backend @admin
-Feature: Minor Trace Chemistry Admin View
- As an administrator
- I want to view Minor Trace Chemistry data in the admin interface
- So that I can browse and manage legacy chemistry results
-
- @positive
- Scenario: Minor Trace Chemistry view is registered in admin
- Given a functioning api
- When I check the registered admin views
- Then "Minor Trace Chemistry" should be in the list of admin views
-
- @positive
- Scenario: Minor Trace Chemistry view is read-only
- Given a functioning api
- Then the Minor Trace Chemistry admin view should not allow create
- And the Minor Trace Chemistry admin view should not allow edit
- And the Minor Trace Chemistry admin view should not allow delete
-
- @positive
- Scenario: Minor Trace Chemistry details page loads
- Given a functioning api
- When I request the Minor Trace Chemistry admin list page
- Then the response status should be 200
- When I request the Minor Trace Chemistry admin detail page for an existing record
- Then the response status should be 200
-
- @positive
- Scenario: Minor Trace Chemistry detail page shows expected fields
- Given a functioning api
- Then the Minor Trace Chemistry admin view should have these fields configured:
- | field |
- | global_id |
- | sample_pt_id |
- | analyte |
- | symbol |
- | sample_value |
- | units |
- | uncertainty |
- | analysis_method |
- | analysis_date |
- | notes |
- | volume |
- | volume_unit |
- | analyses_agency |
diff --git a/tests/features/steps/admin-minor-trace-chemistry.py b/tests/features/steps/admin-minor-trace-chemistry.py
deleted file mode 100644
index 9b193168b..000000000
--- a/tests/features/steps/admin-minor-trace-chemistry.py
+++ /dev/null
@@ -1,143 +0,0 @@
-# ===============================================================================
-# Copyright 2026
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-Step definitions for Minor Trace Chemistry admin view tests.
-These are fast integration tests - no HTTP calls, direct module testing.
-"""
-
-from admin.views.minor_trace_chemistry import MinorTraceChemistryAdmin
-from behave import when, then
-from behave.runner import Context
-
-ADMIN_IDENTITY = MinorTraceChemistryAdmin.identity
-ADMIN_BASE_URL = f"/admin/{ADMIN_IDENTITY}"
-
-
-def _ensure_admin_mounted(context):
- """Ensure admin is mounted on the test app."""
- if not getattr(context, "_admin_mounted", False):
- from admin import create_admin
- from starlette.middleware.sessions import SessionMiddleware
-
- # Add session middleware required by admin
- context.client.app.add_middleware(
- SessionMiddleware, secret_key="test-secret-key"
- )
- create_admin(context.client.app)
- context._admin_mounted = True
-
-
-@when("I check the registered admin views")
-def step_when_i_check_the_registered_admin_views(context: Context):
- from admin.config import create_admin
- from fastapi import FastAPI
-
- app = FastAPI()
- admin = create_admin(app)
- context.admin_views = [v.name for v in admin._views]
-
-
-@then('"{view_name}" should be in the list of admin views')
-def step_then_view_name_should_be_in_the_list_of_admin_views(
- context: Context, view_name: str
-):
- assert view_name in context.admin_views, (
- f"Expected '{view_name}' to be registered in admin views. "
- f"Found: {context.admin_views}"
- )
-
-
-@then("the Minor Trace Chemistry admin view should not allow create")
-def step_then_the_minor_trace_chemistry_admin_view_should_not_allow_create(
- context: Context,
-):
- from db.nma_legacy import NMA_MinorTraceChemistry
-
- view = MinorTraceChemistryAdmin(NMA_MinorTraceChemistry)
- assert view.can_create(None) is False
-
-
-@then("the Minor Trace Chemistry admin view should not allow edit")
-def step_then_the_minor_trace_chemistry_admin_view_should_not_allow_edit(
- context: Context,
-):
- from db.nma_legacy import NMA_MinorTraceChemistry
-
- view = MinorTraceChemistryAdmin(NMA_MinorTraceChemistry)
- assert view.can_edit(None) is False
-
-
-@then("the Minor Trace Chemistry admin view should not allow delete")
-def step_then_the_minor_trace_chemistry_admin_view_should_not_allow_delete(
- context: Context,
-):
- from db.nma_legacy import NMA_MinorTraceChemistry
-
- view = MinorTraceChemistryAdmin(NMA_MinorTraceChemistry)
- assert view.can_delete(None) is False
-
-
-@when("I request the Minor Trace Chemistry admin list page")
-def step_when_i_request_the_minor_trace_chemistry_admin_list_page(context: Context):
- _ensure_admin_mounted(context)
- context.response = context.client.get(f"{ADMIN_BASE_URL}/list")
-
-
-@when("I request the Minor Trace Chemistry admin detail page for an existing record")
-def step_when_i_request_the_minor_trace_chemistry_admin_detail_page_for(
- context: Context,
-):
- _ensure_admin_mounted(context)
- from db.engine import session_ctx
- from db.nma_legacy import NMA_MinorTraceChemistry
-
- with session_ctx() as session:
- record = session.query(NMA_MinorTraceChemistry).first()
- if record:
- context.response = context.client.get(
- f"{ADMIN_BASE_URL}/detail/{record.global_id}"
- )
- else:
- # No records exist, skip by setting a mock 200 response
- context.response = type("Response", (), {"status_code": 200})()
-
-
-@then("the response status should be {status_code:d}")
-def step_then_the_response_status_should_be_status_code_d(
- context: Context, status_code: int
-):
- assert (
- context.response.status_code == status_code
- ), f"Expected status {status_code}, got {context.response.status_code}"
-
-
-@then("the Minor Trace Chemistry admin view should have these fields configured:")
-def step_then_the_minor_trace_chemistry_admin_view_should_have_these_fields(
- context: Context,
-):
- from admin.views.minor_trace_chemistry import MinorTraceChemistryAdmin
-
- expected_fields = [row["field"] for row in context.table]
- actual_fields = MinorTraceChemistryAdmin.fields
-
- for field in expected_fields:
- assert field in actual_fields, (
- f"Expected field '{field}' not found in admin view fields. "
- f"Configured fields: {actual_fields}"
- )
-
-
-# ============= EOF =============================================
diff --git a/tests/integration/test_admin_minor_trace_chemistry.py b/tests/integration/test_admin_minor_trace_chemistry.py
deleted file mode 100644
index f5cf0d0fa..000000000
--- a/tests/integration/test_admin_minor_trace_chemistry.py
+++ /dev/null
@@ -1,237 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-HTTP integration tests for Minor Trace Chemistry admin view.
-
-These tests make real HTTP requests to verify endpoint behavior.
-When these tests pass, the UI should work.
-"""
-
-import uuid
-
-import pytest
-from fastapi import FastAPI
-from fastapi.testclient import TestClient
-from starlette.middleware.sessions import SessionMiddleware
-
-from admin.config import create_admin
-from admin.views.minor_trace_chemistry import MinorTraceChemistryAdmin
-from db.engine import session_ctx
-from db.location import Location, LocationThingAssociation
-from db.nma_legacy import NMA_MinorTraceChemistry, NMA_Chemistry_SampleInfo
-from db.thing import Thing
-
-ADMIN_IDENTITY = MinorTraceChemistryAdmin.identity
-ADMIN_BASE_URL = f"/admin/{ADMIN_IDENTITY}"
-
-
-@pytest.fixture(scope="module")
-def admin_app():
- """Create a FastAPI app with admin interface mounted."""
- app = FastAPI()
-
- # Add session middleware required for admin
- app.add_middleware(SessionMiddleware, secret_key="test-secret-key-for-admin")
-
- # Mount admin interface
- create_admin(app)
-
- return app
-
-
-@pytest.fixture(scope="module")
-def admin_client(admin_app):
- """Create a test client for the admin app."""
- return TestClient(admin_app)
-
-
-@pytest.fixture(scope="module")
-def minor_trace_chemistry_record():
- """Create a minor trace chemistry record for testing."""
- with session_ctx() as session:
- # First create a Location
- location = Location(
- point="POINT(-107.949533 33.809665)",
- elevation=2464.9,
- release_status="draft",
- )
- session.add(location)
- session.commit()
- session.refresh(location)
-
- # Create a Thing (required for NMA_Chemistry_SampleInfo)
- thing = Thing(
- name="INTTEST-WELL-01",
- thing_type="monitoring well",
- release_status="draft",
- )
- session.add(thing)
- session.commit()
- session.refresh(thing)
-
- # Associate Location with Thing
- assoc = LocationThingAssociation(
- location_id=location.id,
- thing_id=thing.id,
- )
- session.add(assoc)
- session.commit()
-
- # Create parent NMA_Chemistry_SampleInfo
- sample_info = NMA_Chemistry_SampleInfo(
- nma_sample_pt_id=uuid.uuid4(),
- nma_sample_point_id="INTTEST01",
- thing_id=thing.id,
- )
- session.add(sample_info)
- session.commit()
- session.refresh(sample_info)
-
- # Create MinorTraceChemistry record
- chemistry = NMA_MinorTraceChemistry(
- nma_global_id=uuid.uuid4(),
- chemistry_sample_info_id=sample_info.id, # Integer FK
- nma_sample_point_id=sample_info.nma_sample_point_id,
- analyte="Arsenic",
- symbol="As",
- sample_value=0.005,
- units="mg/L",
- analysis_method="EPA 200.8",
- analyses_agency="NMED",
- )
- session.add(chemistry)
- session.commit()
- session.refresh(chemistry)
-
- yield chemistry
-
- # Cleanup
- session.delete(chemistry)
- session.delete(sample_info)
- session.delete(assoc)
- session.delete(thing)
- session.delete(location)
- session.commit()
-
-
-class TestMinorTraceChemistryListView:
- """Tests for the list view endpoint."""
-
- def test_list_view_returns_200(self, admin_client):
- """List view should return 200 OK."""
- response = admin_client.get(f"{ADMIN_BASE_URL}/list")
- assert response.status_code == 200, (
- f"Expected 200, got {response.status_code}. "
- f"Response: {response.text[:500]}"
- )
-
- def test_list_view_contains_view_name(self, admin_client):
- """List view should contain the view name."""
- response = admin_client.get(f"{ADMIN_BASE_URL}/list")
- assert response.status_code == 200
- assert "Minor Trace Chemistry" in response.text
-
- def test_no_create_button_in_list_view(self, admin_client):
- """List view should not have a Create button for read-only view."""
- response = admin_client.get(f"{ADMIN_BASE_URL}/list")
- assert response.status_code == 200
- html = response.text.lower()
- assert f'href="{ADMIN_BASE_URL}/create"' not in html
-
-
-class TestMinorTraceChemistryDetailView:
- """Tests for the detail view endpoint."""
-
- def test_detail_view_returns_200(self, admin_client, minor_trace_chemistry_record):
- """Detail view should return 200 OK for existing record."""
- pk = str(minor_trace_chemistry_record.id) # Integer PK
- response = admin_client.get(f"{ADMIN_BASE_URL}/detail/{pk}")
- assert response.status_code == 200, (
- f"Expected 200, got {response.status_code}. "
- f"Response: {response.text[:500]}"
- )
-
- def test_detail_view_shows_analyte(
- self, admin_client, minor_trace_chemistry_record
- ):
- """Detail view should display the analyte."""
- pk = str(minor_trace_chemistry_record.id) # Integer PK
- response = admin_client.get(f"{ADMIN_BASE_URL}/detail/{pk}")
- assert response.status_code == 200
- assert "Arsenic" in response.text
-
- def test_detail_view_shows_parent_relationship(
- self, admin_client, minor_trace_chemistry_record
- ):
- """Detail view should display the parent NMA_Chemistry_SampleInfo."""
- pk = str(minor_trace_chemistry_record.id) # Integer PK
- response = admin_client.get(f"{ADMIN_BASE_URL}/detail/{pk}")
- assert response.status_code == 200
- # The parent relationship should be displayed somehow
- # Check for the field label
- assert "Chemistry Sample Info" in response.text
-
- def test_detail_view_404_for_nonexistent_record(self, admin_client):
- """Detail view should return 404 for non-existent record."""
- fake_pk = "999999999" # Integer PK that doesn't exist
- response = admin_client.get(f"{ADMIN_BASE_URL}/detail/{fake_pk}")
- assert response.status_code == 404
-
-
-class TestMinorTraceChemistryReadOnlyRestrictions:
- """Tests for read-only restrictions."""
-
- def test_create_endpoint_forbidden(self, admin_client):
- """Create endpoint should be forbidden for read-only view."""
- response = admin_client.get(f"{ADMIN_BASE_URL}/create")
- # Should be 403 or redirect, not 200
- assert response.status_code in (
- 403,
- 302,
- 307,
- ), f"Expected 403 or redirect, got {response.status_code}"
-
- def test_edit_endpoint_forbidden(self, admin_client, minor_trace_chemistry_record):
- """Edit endpoint should be forbidden for read-only view."""
- pk = str(minor_trace_chemistry_record.id) # Integer PK
- response = admin_client.get(f"{ADMIN_BASE_URL}/edit/{pk}")
- # Should be 403 or redirect, not 200
- assert response.status_code in (
- 403,
- 302,
- 307,
- ), f"Expected 403 or redirect, got {response.status_code}"
-
- def test_delete_endpoint_forbidden(
- self, admin_client, minor_trace_chemistry_record
- ):
- """Delete endpoint should be forbidden for read-only view."""
- pk = str(minor_trace_chemistry_record.id) # Integer PK
- response = admin_client.post(
- f"{ADMIN_BASE_URL}/delete",
- data={"pks": [pk]},
- )
- # Should be 403, redirect, or 404/405 (route may not exist for read-only)
- assert response.status_code in (
- 403,
- 302,
- 307,
- 404,
- 405,
- ), f"Expected 403/redirect/404/405, got {response.status_code}"
-
-
-# ============= EOF =============================================
diff --git a/tests/test_admin_minor_trace_chemistry.py b/tests/test_admin_minor_trace_chemistry.py
deleted file mode 100644
index 4ec1705d8..000000000
--- a/tests/test_admin_minor_trace_chemistry.py
+++ /dev/null
@@ -1,217 +0,0 @@
-# ===============================================================================
-# Copyright 2025
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-Unit tests for Minor Trace Chemistry admin view configuration.
-
-These tests verify the admin view is properly configured without requiring
-a running server or database.
-
-Updated for Integer PK schema:
-- id: Integer PK (autoincrement)
-- nma_global_id: Legacy GlobalID UUID (UNIQUE)
-- chemistry_sample_info_id: Integer FK to NMA_Chemistry_SampleInfo.id
-- nma_chemistry_sample_info_uuid: Legacy UUID FK (for audit)
-"""
-
-import pytest
-from fastapi import FastAPI
-
-from admin.config import create_admin
-from admin.views.minor_trace_chemistry import MinorTraceChemistryAdmin
-from db.nma_legacy import NMA_MinorTraceChemistry
-
-
-class TestMinorTraceChemistryAdminRegistration:
- """Tests for MinorTraceChemistry admin view registration."""
-
- def test_minor_trace_chemistry_view_is_registered(self):
- """Minor Trace Chemistry should appear in admin views."""
- app = FastAPI()
- admin = create_admin(app)
- view_names = [v.name for v in admin._views]
-
- assert "Minor Trace Chemistry" in view_names, (
- f"Expected 'Minor Trace Chemistry' to be registered in admin views. "
- f"Found: {view_names}"
- )
-
- def test_view_has_correct_label(self):
- """View should have proper label for sidebar display."""
- view = MinorTraceChemistryAdmin(NMA_MinorTraceChemistry)
- assert view.label == "Minor Trace Chemistry"
-
- def test_class_has_flask_icon_configured(self):
- """View class should have flask icon configured for chemistry data."""
- # Note: icon attribute may be processed by starlette-admin on instantiation
- # so we check the class attribute directly
- assert MinorTraceChemistryAdmin.icon == "fa fa-flask"
-
-
-class TestMinorTraceChemistryAdminReadOnly:
- """Tests for read-only restrictions on legacy data."""
-
- @pytest.fixture
- def view(self):
- """Create a MinorTraceChemistryAdmin instance for testing."""
- return MinorTraceChemistryAdmin(NMA_MinorTraceChemistry)
-
- def test_can_create_returns_false(self, view):
- """Create should be disabled for legacy data."""
- assert view.can_create(None) is False
-
- def test_can_edit_returns_false(self, view):
- """Edit should be disabled for legacy data."""
- assert view.can_edit(None) is False
-
- def test_can_delete_returns_false(self, view):
- """Delete should be disabled for legacy data."""
- assert view.can_delete(None) is False
-
- def test_read_only_methods_are_callable(self, view):
- """Permission methods should be callable (not boolean attributes)."""
- # This test catches the bug where can_create/can_edit/can_delete
- # were set as boolean attributes instead of methods
- assert callable(view.can_create)
- assert callable(view.can_edit)
- assert callable(view.can_delete)
-
-
-class TestMinorTraceChemistryAdminListView:
- """Tests for list view configuration."""
-
- @pytest.fixture
- def view(self):
- """Create a MinorTraceChemistryAdmin instance for testing."""
- return MinorTraceChemistryAdmin(NMA_MinorTraceChemistry)
-
- def test_list_fields_include_required_columns(self, view):
- """List view should show key chemistry data columns."""
- from starlette_admin.fields import HasOne
-
- # Get field names (handling both string fields and HasOne fields)
- field_names = []
- for f in view.list_fields:
- if isinstance(f, str):
- field_names.append(f)
- elif isinstance(f, HasOne):
- field_names.append(f.name)
- else:
- field_names.append(getattr(f, "name", str(f)))
-
- required_columns = [
- "id", # Integer PK
- "nma_global_id", # Legacy UUID
- "chemistry_sample_info", # HasOne relationship to parent
- "analyte",
- "sample_value",
- "units",
- ]
- for col in required_columns:
- assert col in field_names, f"Expected '{col}' in list_fields"
-
- def test_default_sort_by_analysis_date(self, view):
- """Default sort should be by analysis_date descending."""
- assert view.fields_default_sort == [("analysis_date", True)]
-
- def test_page_size_is_50(self, view):
- """Default page size should be 50."""
- assert view.page_size == 50
-
- def test_page_size_options_available(self, view):
- """Multiple page size options should be available."""
- assert 25 in view.page_size_options
- assert 50 in view.page_size_options
- assert 100 in view.page_size_options
-
-
-class TestMinorTraceChemistryAdminFormView:
- """Tests for form/detail view configuration."""
-
- @pytest.fixture
- def view(self):
- """Create a MinorTraceChemistryAdmin instance for testing."""
- return MinorTraceChemistryAdmin(NMA_MinorTraceChemistry)
-
- def test_form_includes_all_chemistry_fields(self):
- """Form should include all relevant chemistry data fields in configuration."""
- from starlette_admin.fields import HasOne
-
- # Check the class-level configuration
- # Note: chemistry_sample_info is a HasOne field, not a string
- expected_string_fields = [
- "id", # Integer PK
- "nma_global_id", # Legacy GlobalID
- "nma_chemistry_sample_info_uuid", # Legacy UUID FK
- "analyte",
- "symbol",
- "sample_value",
- "units",
- "uncertainty",
- "analysis_method",
- "analysis_date",
- "notes",
- "volume",
- "volume_unit",
- "analyses_agency",
- ]
- configured_fields = MinorTraceChemistryAdmin.fields
-
- # Check string fields
- for field in expected_string_fields:
- assert (
- field in configured_fields
- ), f"Expected '{field}' in configured fields"
-
- # Check that chemistry_sample_info HasOne relationship is configured
- has_one_fields = [f for f in configured_fields if isinstance(f, HasOne)]
- assert (
- len(has_one_fields) == 1
- ), "Expected one HasOne field for parent relationship"
- assert has_one_fields[0].name == "chemistry_sample_info"
-
- def test_field_labels_are_human_readable(self, view):
- """Field labels should be human-readable."""
- assert view.field_labels.get("id") == "ID"
- assert view.field_labels.get("nma_global_id") == "NMA GlobalID (Legacy)"
- assert view.field_labels.get("sample_value") == "Sample Value"
- assert view.field_labels.get("analysis_date") == "Analysis Date"
-
- def test_searchable_fields_include_key_fields(self, view):
- """Searchable fields should include commonly searched columns."""
- assert "nma_global_id" in view.searchable_fields
- assert "analyte" in view.searchable_fields
- assert "symbol" in view.searchable_fields
- assert "analyses_agency" in view.searchable_fields
-
-
-class TestMinorTraceChemistryAdminIntegerPK:
- """Tests for Integer PK configuration."""
-
- @pytest.fixture
- def view(self):
- """Create a MinorTraceChemistryAdmin instance for testing."""
- return MinorTraceChemistryAdmin(NMA_MinorTraceChemistry)
-
- def test_pk_attr_is_id(self, view):
- """Primary key attribute should be 'id'."""
- assert view.pk_attr == "id"
-
- def test_pk_type_is_int(self, view):
- """Primary key type should be int."""
- assert view.pk_type == int
-
-
-# ============= EOF =============================================
diff --git a/tests/test_admin_views.py b/tests/test_admin_views.py
deleted file mode 100644
index 9696ed1ba..000000000
--- a/tests/test_admin_views.py
+++ /dev/null
@@ -1,110 +0,0 @@
-# ===============================================================================
-# Copyright 2026
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# ===============================================================================
-"""
-Tests for admin views module.
-
-These tests ensure admin views can be imported without errors,
-catching missing imports and syntax issues early in CI.
-"""
-
-import importlib
-import pkgutil
-
-import pytest
-
-
-class TestAdminViewsImport:
- """Tests that verify all admin views can be imported successfully."""
-
- def test_admin_package_imports(self):
- """
- Admin package should import without errors.
-
- This catches missing imports like Request, HasOne, etc.
- """
- import admin # noqa: F401
-
- def test_admin_views_package_imports(self):
- """Admin views subpackage should import without errors."""
- import admin.views # noqa: F401
-
- def test_all_view_modules_import(self):
- """
- All individual admin view modules should import successfully.
-
- Iterates through all modules in admin.views and verifies each can be imported.
- """
- import admin.views
-
- failed_imports = []
-
- for importer, modname, ispkg in pkgutil.iter_modules(admin.views.__path__):
- if modname.startswith("_"):
- continue
- full_name = f"admin.views.{modname}"
- try:
- importlib.import_module(full_name)
- except Exception as e:
- failed_imports.append((full_name, str(e)))
-
- assert (
- not failed_imports
- ), f"Failed to import admin view modules:\n" + "\n".join(
- f" {name}: {err}" for name, err in failed_imports
- )
-
- @pytest.mark.parametrize(
- "view_module",
- [
- "base",
- "thing",
- "location",
- "observation",
- "sample",
- "contact",
- "chemistry_sampleinfo",
- "major_chemistry",
- "minor_trace_chemistry",
- ],
- )
- def test_core_view_modules_import(self, view_module: str):
- """Core admin view modules should import without errors."""
- importlib.import_module(f"admin.views.{view_module}")
-
-
-class TestAdminViewsConfiguration:
- """Tests for admin view configuration validity."""
-
- def test_all_exported_views_have_required_attributes(self):
- """All exported admin views should have required attributes."""
- import admin.views
-
- for name in admin.views.__all__:
- view_class = getattr(admin.views, name)
-
- # All views should have a name attribute
- assert hasattr(
- view_class, "name"
- ), f"{view_class.__name__} missing 'name' attribute"
-
- # All views inheriting from ModelView should have pk_attr
- if hasattr(view_class, "model"):
- assert hasattr(
- view_class, "pk_attr"
- ), f"{view_class.__name__} missing 'pk_attr' attribute"
-
-
-# ============= EOF =============================================
diff --git a/tests/test_lazy_admin.py b/tests/test_lazy_admin.py
deleted file mode 100644
index ac2f22448..000000000
--- a/tests/test_lazy_admin.py
+++ /dev/null
@@ -1,34 +0,0 @@
-import os
-from collections.abc import Iterable
-
-from core.factory import create_api_app
-from fastapi.testclient import TestClient
-
-
-def _iter_route_paths(routes: Iterable) -> Iterable[str]:
- for route in routes:
- path = getattr(route, "path", None)
- if path:
- yield path
- nested = getattr(route, "routes", None)
- if nested:
- yield from _iter_route_paths(nested)
-
-
-def _has_admin_route(routes: Iterable) -> bool:
- return any(path.startswith("/admin") for path in _iter_route_paths(routes))
-
-
-def test_admin_is_lazy_loaded_on_first_admin_request():
- os.environ["SESSION_SECRET_KEY"] = "test-session-secret-key"
- app = create_api_app()
-
- assert not _has_admin_route(app.routes)
- assert getattr(app.state, "admin_configured", False) is False
-
- with TestClient(app) as client:
- response = client.get("/admin", follow_redirects=False)
-
- assert response.status_code in {200, 302, 307}
- assert app.state.admin_configured is True
- assert _has_admin_route(app.routes)
diff --git a/uv.lock b/uv.lock
index 81c778f97..195ad31ef 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1631,7 +1631,6 @@ dependencies = [
{ name = "httpx" },
{ name = "idna" },
{ name = "iniconfig" },
- { name = "itsdangerous" },
{ name = "jinja2" },
{ name = "mako" },
{ name = "markupsafe" },
@@ -1676,7 +1675,6 @@ dependencies = [
{ name = "sqlalchemy-utils" },
{ name = "sqlparse" },
{ name = "starlette" },
- { name = "starlette-admin", extra = ["i18n"] },
{ name = "typer" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
@@ -1750,7 +1748,6 @@ requires-dist = [
{ name = "httpx", specifier = "==0.28.1" },
{ name = "idna", specifier = "==3.18" },
{ name = "iniconfig", specifier = "==2.3.0" },
- { name = "itsdangerous", specifier = ">=2.2.0" },
{ name = "jinja2", specifier = "==3.1.6" },
{ name = "mako", specifier = "==1.3.12" },
{ name = "markupsafe", specifier = "==3.0.3" },
@@ -1795,7 +1792,6 @@ requires-dist = [
{ name = "sqlalchemy-utils", specifier = "==0.42.1" },
{ name = "sqlparse", specifier = ">=0.5.5" },
{ name = "starlette", specifier = "==1.3.1" },
- { name = "starlette-admin", extras = ["i18n"], specifier = "==0.17.1" },
{ name = "typer", specifier = "==0.27.0" },
{ name = "typing-extensions", specifier = "==4.16.0" },
{ name = "typing-inspection", specifier = "==0.4.2" },
@@ -3070,25 +3066,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
-[[package]]
-name = "starlette-admin"
-version = "0.17.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "jinja2" },
- { name = "python-multipart" },
- { name = "starlette" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d3/1d/49347d67cf11a453d6f8379e0b87e6c1ecc671dee347f61de8240c5d2b11/starlette_admin-0.17.1.tar.gz", hash = "sha256:7bdeaf1c30fd9036ef3779fb0255002d3d18aaf6f7e674200e21c604ee563fc7", size = 2106865, upload-time = "2026-07-20T06:13:58.132Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fe/d1/5fc2df30b98b59cc81b8184b66ca5d76fe194a7a9ff5197b70a23e49fc0b/starlette_admin-0.17.1-py3-none-any.whl", hash = "sha256:685615945d55de636879e3523ec70a6419176f7cd6f04a17470e35670f96b972", size = 2183488, upload-time = "2026-07-20T06:13:56.048Z" },
-]
-
-[package.optional-dependencies]
-i18n = [
- { name = "babel" },
-]
-
[[package]]
name = "tinydb"
version = "4.8.2"
From 2261f49ed9790c507d02dd802ef9c3be537a9296 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Thu, 30 Jul 2026 11:15:10 -0600
Subject: [PATCH 019/151] feat(lexicon): add new "USFS, Cibola NF, Supervisor's
Office" organization term
---
core/lexicon.json | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/core/lexicon.json b/core/lexicon.json
index 890ebc486..5317129ae 100644
--- a/core/lexicon.json
+++ b/core/lexicon.json
@@ -4371,6 +4371,13 @@
"term": "USFS, Cibola NF, Magdalena Ranger District",
"definition": "USFS, Cibola NF, Magdalena Ranger District"
},
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "USFS, Cibola NF, Supervisor's Office",
+ "definition": "USFS, Cibola NF, Supervisor's Office"
+ },
{
"categories": [
"organization"
From 1bbcb017a24bed79c3995f88d046aecf63e44189 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Thu, 30 Jul 2026 13:37:18 -0600
Subject: [PATCH 020/151] feat(lexicon): add new "Lightning Dock Zanskar" and
"Sparrowhawk Farm" organization term
---
core/lexicon.json | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/core/lexicon.json b/core/lexicon.json
index 5317129ae..cf2396298 100644
--- a/core/lexicon.json
+++ b/core/lexicon.json
@@ -3937,6 +3937,13 @@
"term": "Las Lagunitas HOA",
"definition": "Las Lagunitas HOA"
},
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "Lightning Dock Zanskar",
+ "definition": "Lightning Dock Zanskar"
+ },
{
"categories": [
"organization"
@@ -4224,6 +4231,13 @@
"term": "Spanish Stirrup Rockshop",
"definition": "Spanish Stirrup Rockshop"
},
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "Sparrowhawk Farm",
+ "definition": "Sparrowhawk Farm"
+ },
{
"categories": [
"organization"
@@ -4371,7 +4385,7 @@
"term": "USFS, Cibola NF, Magdalena Ranger District",
"definition": "USFS, Cibola NF, Magdalena Ranger District"
},
- {
+ {
"categories": [
"organization"
],
From 4fe34683321ca6cae444d1c8431a4fdc8bb7dc27 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 3 Aug 2026 15:07:46 +0000
Subject: [PATCH 021/151] build(deps): bump actions/stale from 10 to 11
Bumps [actions/stale](https://github.com/actions/stale) from 10 to 11.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v10...v11)
---
updated-dependencies:
- dependency-name: actions/stale
dependency-version: '11'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
---
.github/workflows/stale-prs.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/stale-prs.yml b/.github/workflows/stale-prs.yml
index 42dc48246..9579c2ccb 100644
--- a/.github/workflows/stale-prs.yml
+++ b/.github/workflows/stale-prs.yml
@@ -13,7 +13,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v10
+ - uses: actions/stale@v11
with:
days-before-pr-stale: 14
days-before-pr-close: 0
From 2c17ce6301f11db4108bb6d977597b281a3af4b6 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Wed, 15 Jul 2026 13:57:50 -0600
Subject: [PATCH 022/151] fix(tests): remove hardcoded group id assumption
test_get_project_area assumed its "Test Group Foo" fixture would always get database id 1, which only held because nothing else in the suite created a Group row first. Any earlier-running test that inserts a Group (e.g. a data-migration test) shifts the sequence and breaks this test with an unrelated 404.
Use the fixture's actual group.id instead of the literal 1.
---
tests/test_geospatial.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/test_geospatial.py b/tests/test_geospatial.py
index a25ce24cb..21608e80d 100644
--- a/tests/test_geospatial.py
+++ b/tests/test_geospatial.py
@@ -117,7 +117,7 @@ def populate():
session.add(group)
session.commit()
- yield
+ yield group
# Cleanup
session.delete(loc1)
@@ -128,15 +128,15 @@ def populate():
session.commit()
-def test_get_project_area():
- response = client.get("/geospatial/project-area/1")
+def test_get_project_area(populate):
+ response = client.get(f"/geospatial/project-area/{populate.id}")
assert response.status_code == 200
data = response.json()
assert "type" in data
assert data["type"] == "FeatureCollection"
assert "features" in data
assert len(data["features"]) > 0
- assert data["features"][0]["properties"]["group_id"] == 1
+ assert data["features"][0]["properties"]["group_id"] == populate.id
assert data["features"][0]["properties"]["group_name"] == "Test Group Foo"
assert (
data["features"][0]["properties"]["group_description"]
From 32c6462afb05f433863acf96d35ad4147052a673 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Wed, 15 Jul 2026 14:12:54 -0600
Subject: [PATCH 023/151] fix(build): package data_migrations with the app
pyproject.toml's explicit setuptools packages list omitted data_migrations, so the installed `oco` CLI couldn't import it at all -- `oco data-migrations status/run` failed with ModuleNotFoundError for every migration, not just new ones. Running via `uv run pytest`/`python -c` masked this because pytest prepends the repo root to sys.path; the installed console-script entry point does not.
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index a8f7305f3..dd4eb7fe0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -106,7 +106,7 @@ dependencies = [
package = true
[tool.setuptools]
-packages = ["alembic", "cli", "core", "db", "schemas", "services", "transfers"]
+packages = ["alembic", "cli", "core", "data_migrations", "db", "schemas", "services", "transfers"]
[project.scripts]
oco = "cli.cli:cli"
From 6f171b85f307fe22794b8aa5dbe9cb8582bd875f Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Wed, 15 Jul 2026 14:21:06 -0600
Subject: [PATCH 024/151] test(fixtures): default shared fixtures to public
The upcoming ogc_* view filter excludes non-public records, and nine currently-passing tests in test_ogc.py assert that rows backed by the shared location/water_well_thing/group fixtures ARE present in ogc_* responses. Their "draft" default was an arbitrary safe value, not a deliberate test input -- confirmed by grepping the suite for anything that depends on it being specifically "draft".
Flips the three fixtures plus one inline Group(...)construction in test_ogc.py that bypassed the fixture.
---
tests/conftest.py | 6 +++---
tests/test_ogc.py | 4 ++--
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/tests/conftest.py b/tests/conftest.py
index f77d8fa3b..41c3e1808 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -132,7 +132,7 @@ def location():
point="POINT(-107.949533 33.809665)",
elevation=2464.9,
county="Sierra",
- release_status="draft",
+ release_status="public",
state="NM",
quad_name="Hillsboro Peak",
)
@@ -180,7 +180,7 @@ def water_well_thing(location):
name="Test Well",
first_visit_date="2023-03-03",
thing_type="water well",
- release_status="draft",
+ release_status="public",
well_depth=10,
hole_depth=10,
well_casing_diameter=5.0,
@@ -1039,7 +1039,7 @@ def observation_to_delete(water_chemistry_sample, sensor):
def group(water_well_thing):
with session_ctx() as session:
group = Group(
- release_status="draft",
+ release_status="public",
name="Test Group",
description="This is a test group.",
project_area="MULTIPOLYGON(((-107.2 33.6, -106.6 33.6, -106.6 34.2, -107.2 34.2, -107.2 33.6)))",
diff --git a/tests/test_ogc.py b/tests/test_ogc.py
index 42a0c9843..f711b9caa 100644
--- a/tests/test_ogc.py
+++ b/tests/test_ogc.py
@@ -413,7 +413,7 @@ def test_ogc_actively_monitored_wells_exposes_water_level_network_group_wells(
group = Group(
name="Water Level Network",
group_type="Monitoring Plan",
- release_status="draft",
+ release_status="public",
)
session.add(group)
session.flush()
@@ -462,7 +462,7 @@ def test_ogc_actively_monitored_wells_excludes_latest_not_currently_monitored(
group = Group(
name="Water Level Network",
group_type="Monitoring Plan",
- release_status="draft",
+ release_status="public",
)
session.add(group)
session.flush()
From dbc760bf91bb072b8402a8f52190e1f1411e432f Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Wed, 15 Jul 2026 14:30:57 -0600
Subject: [PATCH 025/151] feat(ogc): filter ogc_* views to public records
Every ogc_* view/materialized view selected release_status but never filtered on it, so the unauthenticated /ogcapi endpoints have been serving private and draft records alongside public ones (e.g. water_wells: 1,145 private + 140 draft rows next to 8,678 public).
Adds "AND release_status = 'public'" to all 21 existing ogc_* relations and introduces ogc_locations (previously the locations collection pointed straight at the raw location table, which has no filter at all). ogc_actively_monitored_wells gets no predicate of its own -- it already inherits public-only rows transitively through its join to ogc_water_well_summary; adding a redundant filter on status_history.release_status would repeat a mistake already made and reverted once (see w1x2y3z4a5b6), since that column is never populated by the transfer scripts.
Fully reversible: downgrade() rebuilds the same relations with the byte-identical unfiltered SQL that was in production before this migration (ogc_locations is the exception, since it didn't exist pre-migration -- downgrade drops it rather than recreating it unfiltered).
Repoints core/pygeoapi-config.yml's locations provider at the new view in the same commit, since shipping the view without the config change (or vice versa) leaves the collection either unfiltered or broken.
Layer visibility (which collections are published)is unchanged. That's separate, out-of-scope work.
---
...blic_release_status_filter_to_ogc_views.py | 1276 +++++++++++++++++
core/pygeoapi-config.yml | 2 +-
2 files changed, 1277 insertions(+), 1 deletion(-)
create mode 100644 alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py
diff --git a/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py b/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py
new file mode 100644
index 000000000..3cc644393
--- /dev/null
+++ b/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py
@@ -0,0 +1,1276 @@
+"""apply public release_status filter to ogc views
+
+Restricts every ogc_* view/materialized view to release_status = 'public'
+so that published, unauthenticated OGC endpoints (/ogcapi) never expose
+private or draft records. Reversible: downgrade() recreates the same 22
+relations with the byte-identical unfiltered SQL that was in production
+before this migration, so "no predicate" is restored exactly rather than
+approximated.
+
+ogc_actively_monitored_wells gets no predicate of its own -- it inherits
+public-only rows transitively once ogc_water_well_summary is filtered (see
+alembic/versions/w1x2y3z4a5b6_drop_child_release_filters_from_ngwmn_views.py
+for why an extra child-table release_status filter here would be wrong).
+Because it depends on ogc_water_well_summary via a direct JOIN, it must be
+dropped before ogc_water_well_summary and recreated after.
+
+ogc_locations does not exist before this migration -- core/pygeoapi-config.yml
+points the locations collection directly at the raw location table. This
+migration creates ogc_locations for the first time (explicit column list,
+no SELECT *, matching every other view in this file) and a separate change
+repoints that one config line at it. Since ogc_locations never existed
+unfiltered in production, downgrade() drops it rather than recreating an
+unfiltered copy.
+
+Revision ID: f4a5b6c7d8e9
+Revises: y3z4a5b6c7d8
+Create Date: 2026-07-14 00: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 = "f4a5b6c7d8e9"
+down_revision: Union[str, Sequence[str], None] = "y3z4a5b6c7d8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+REQUIRED_TABLES = {
+ "thing",
+ "location",
+ "location_thing_association",
+ "group",
+ "group_thing_association",
+ "status_history",
+ "observation",
+ "sample",
+ "field_activity",
+ "field_event",
+ "data_provenance",
+ "NMA_MajorChemistry",
+ "NMA_Chemistry_SampleInfo",
+ "NMA_MinorTraceChemistry",
+}
+
+LATEST_LOCATION_CTE = """
+SELECT DISTINCT ON (lta.thing_id)
+ lta.thing_id,
+ lta.location_id,
+ lta.effective_start
+FROM location_thing_association AS lta
+WHERE lta.effective_end IS NULL
+ORDER BY lta.thing_id, lta.effective_start DESC
+""".strip()
+
+# The 11 thing-type views still in scope after
+# s4t5u6v7w8x9_drop_unused_well_type_ogc_views.py removed the well-subtype
+# variants (abandoned_wells, artesian_wells, dry_holes, dug_wells,
+# exploration_wells, injection_wells, monitoring_wells, observation_wells,
+# piezometers, production_wells, test_wells).
+THING_VIEWS = [
+ ("water_wells", "water well"),
+ ("springs", "spring"),
+ ("diversions_surface_water", "diversion of surface water, etc."),
+ ("ephemeral_streams", "ephemeral stream"),
+ ("lakes_ponds_reservoirs", "lake, pond or reservoir"),
+ ("meteorological_stations", "meteorological station"),
+ ("other_things", "other"),
+ ("outfalls_wastewater_return_flow", "outfall of wastewater or return flow"),
+ ("perennial_streams", "perennial stream"),
+ ("rock_sample_locations", "rock sample location"),
+ ("soil_gas_sample_locations", "soil gas sample location"),
+]
+
+
+def _safe_view_id(view_id: str) -> str:
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", view_id):
+ raise ValueError(f"Unsafe view id: {view_id!r}")
+ return view_id
+
+
+def _drop_view_or_materialized_view(view_name: str) -> None:
+ # DROP VIEW IF EXISTS / DROP MATERIALIZED VIEW IF EXISTS only suppress
+ # "relation does not exist" -- Postgres still raises WrongObjectType if
+ # the relation exists as the other kind (e.g. DROP VIEW against an
+ # existing materialized view), so the relation's actual kind must be
+ # checked first rather than trying both blindly.
+ bind = op.get_bind()
+ relkind = bind.execute(
+ text("SELECT relkind FROM pg_class WHERE oid = to_regclass(:name)"),
+ {"name": view_name},
+ ).scalar()
+ if relkind == "m":
+ op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {view_name}"))
+ elif relkind == "v":
+ op.execute(text(f"DROP VIEW IF EXISTS {view_name}"))
+
+
+def _check_required_tables() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ existing_tables = set(inspector.get_table_names(schema="public"))
+ missing = REQUIRED_TABLES - existing_tables
+ if missing:
+ raise RuntimeError(
+ "Cannot apply public release_status filter to OGC views. "
+ f"Missing required tables: {', '.join(sorted(missing))}"
+ )
+
+
+def _create_thing_view(view_id: str, thing_type: str, public_only: bool) -> str:
+ safe_view_id = _safe_view_id(view_id)
+ escaped_thing_type = thing_type.replace("'", "''")
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE VIEW ogc_{safe_view_id} AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ )
+ SELECT
+ t.id,
+ t.name,
+ t.first_visit_date,
+ t.nma_pk_welldata,
+ t.well_depth,
+ t.hole_depth,
+ t.well_casing_diameter,
+ t.well_casing_depth,
+ t.well_completion_date,
+ t.well_driller_name,
+ t.well_construction_method,
+ t.well_pump_type,
+ t.well_pump_depth,
+ t.formation_completion_code,
+ t.nma_formation_zone,
+ t.release_status,
+ l.elevation,
+ l.point
+ FROM thing AS t
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE t.thing_type = '{escaped_thing_type}'{release_filter}
+ """
+
+
+def _create_latest_depth_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_latest_depth_to_water_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ ranked_obs AS (
+ SELECT
+ fe.thing_id,
+ o.id AS observation_id,
+ o.observation_datetime,
+ o.value,
+ 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{release_filter}
+ )
+ 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(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_avg_tds_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ tds_obs AS (
+ SELECT
+ csi.thing_id,
+ mc.id AS major_chemistry_id,
+ COALESCE(mc."AnalysisDate", csi."CollectionDate")::date AS observation_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'
+ ){release_filter}
+ )
+ 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.observation_date) AS first_tds_observation_date,
+ MAX(to2.observation_date) AS last_tds_observation_date,
+ 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 _create_latest_tds_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE VIEW ogc_latest_tds_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ tds_obs AS (
+ SELECT
+ csi.thing_id,
+ mc.id AS major_chemistry_id,
+ COALESCE(mc."AnalysisDate", csi."CollectionDate") AS observation_datetime,
+ 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'
+ ){release_filter}
+ ),
+ ranked_tds AS (
+ SELECT
+ to2.thing_id,
+ to2.major_chemistry_id,
+ to2.observation_datetime,
+ to2.sample_value,
+ to2.units,
+ ROW_NUMBER() OVER (
+ PARTITION BY to2.thing_id
+ ORDER BY to2.observation_datetime DESC NULLS LAST, to2.major_chemistry_id DESC
+ ) AS rn
+ FROM tds_obs AS to2
+ )
+ SELECT
+ t.id AS id,
+ t.name,
+ t.thing_type,
+ rt.major_chemistry_id,
+ rt.observation_datetime::date AS latest_tds_observation_date,
+ rt.sample_value AS latest_tds_value,
+ rt.units AS latest_tds_units,
+ l.point
+ FROM ranked_tds AS rt
+ JOIN thing AS t ON t.id = rt.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE rt.rn = 1
+ """
+
+
+def _create_depth_to_water_trend_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_depth_to_water_trend_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ obs AS (
+ SELECT
+ fe.thing_id,
+ o.observation_datetime,
+ (o.value - COALESCE(o.measuring_point_height, 0)) AS depth_to_water_bgs
+ FROM observation AS o
+ JOIN sample AS s ON s.id = o.sample_id
+ JOIN field_activity AS fa ON fa.id = s.field_activity_id
+ JOIN field_event AS fe ON fe.id = fa.field_event_id
+ JOIN thing AS t ON t.id = fe.thing_id
+ WHERE
+ t.thing_type = 'water well'
+ AND fa.activity_type = 'groundwater level'
+ AND o.value IS NOT NULL
+ AND o.observation_datetime IS NOT NULL{release_filter}
+ ),
+ agg AS (
+ SELECT
+ ob.thing_id,
+ COUNT(*)::integer AS record_count,
+ MIN(ob.observation_datetime) AS first_observation_datetime,
+ MAX(ob.observation_datetime) AS last_observation_datetime,
+ EXTRACT(EPOCH FROM (MAX(ob.observation_datetime) - MIN(ob.observation_datetime)))
+ / 31557600.0 AS span_years,
+ REGR_SLOPE(
+ ob.depth_to_water_bgs,
+ EXTRACT(EPOCH FROM ob.observation_datetime)
+ ) * 31557600.0 AS slope_ft_per_year
+ FROM obs AS ob
+ GROUP BY ob.thing_id
+ )
+ SELECT
+ t.id AS id,
+ t.name,
+ t.thing_type,
+ a.record_count,
+ a.first_observation_datetime,
+ a.last_observation_datetime,
+ a.span_years,
+ a.slope_ft_per_year,
+ CASE
+ WHEN a.record_count >= 10 OR (a.record_count >= 4 AND a.span_years >= 2.0) THEN
+ CASE
+ WHEN a.slope_ft_per_year IS NULL THEN 'not enough data'
+ WHEN a.slope_ft_per_year > 0.25 THEN 'increasing'
+ WHEN a.slope_ft_per_year < -0.25 THEN 'decreasing'
+ ELSE 'stable'
+ END
+ ELSE 'not enough data'
+ END AS trend_category,
+ l.point
+ FROM agg AS a
+ JOIN thing AS t ON t.id = a.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ """
+
+
+def _create_water_well_summary_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_water_well_summary AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ wl_obs AS (
+ SELECT
+ fe.thing_id,
+ o.id AS observation_id,
+ o.observation_datetime,
+ (o.value - COALESCE(o.measuring_point_height, 0)) AS water_level
+ FROM observation AS o
+ JOIN sample AS s ON s.id = o.sample_id
+ JOIN field_activity AS fa ON fa.id = s.field_activity_id
+ JOIN field_event AS fe ON fe.id = fa.field_event_id
+ JOIN thing AS t ON t.id = fe.thing_id
+ WHERE
+ t.thing_type = 'water well'
+ AND fa.activity_type = 'groundwater level'
+ AND o.value IS NOT NULL
+ AND o.observation_datetime IS NOT NULL{release_filter}
+ ),
+ wl_agg AS (
+ SELECT
+ w.thing_id,
+ COUNT(*)::integer AS total_water_levels,
+ MIN(w.water_level) AS min_water_level,
+ MAX(w.water_level) AS max_water_level,
+ REGR_SLOPE(
+ w.water_level,
+ EXTRACT(EPOCH FROM w.observation_datetime)
+ ) * 31557600.0 AS water_level_trend_ft_per_year
+ FROM wl_obs AS w
+ GROUP BY w.thing_id
+ ),
+ wl_last AS (
+ SELECT
+ ranked.thing_id,
+ ranked.water_level AS last_water_level,
+ ranked.observation_datetime AS last_water_level_datetime
+ FROM (
+ SELECT
+ w.thing_id,
+ w.water_level,
+ w.observation_datetime,
+ ROW_NUMBER() OVER (
+ PARTITION BY w.thing_id
+ ORDER BY w.observation_datetime DESC, w.observation_id DESC
+ ) AS rn
+ FROM wl_obs AS w
+ ) AS ranked
+ WHERE ranked.rn = 1
+ )
+ SELECT
+ t.id AS id,
+ t.name,
+ t.well_depth,
+ l.elevation,
+ dpl.collection_method AS elevation_method,
+ t.nma_formation_zone AS formation_zone,
+ wa.total_water_levels,
+ wl.last_water_level,
+ wl.last_water_level_datetime,
+ wa.min_water_level,
+ wa.max_water_level,
+ wa.water_level_trend_ft_per_year,
+ 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
+ JOIN wl_agg AS wa ON wa.thing_id = t.id
+ LEFT JOIN wl_last AS wl ON wl.thing_id = t.id
+ LEFT JOIN LATERAL (
+ SELECT dp.collection_method
+ FROM data_provenance AS dp
+ WHERE
+ dp.target_table = 'location'
+ AND dp.target_id = l.id
+ AND dp.field_name = 'elevation'
+ ORDER BY dp.id DESC
+ LIMIT 1
+ ) AS dpl ON true
+ WHERE t.thing_type = 'water well'
+ AND wa.total_water_levels > 0
+ """
+
+
+# Static analyte columns for major chemistry pivots.
+# Includes aliases observed in current DB values (e.g., Ca(total), IONBAL, TAn, TCat, Na+K).
+STATIC_ANALYTE_COLUMNS_MAJOR: list[tuple[str, str]] = [
+ ("tds", "tds"),
+ ("calcium", "calcium"),
+ ("calcium_total", "calcium_total"),
+ ("magnesium", "magnesium"),
+ ("magnesium_total", "magnesium_total"),
+ ("sodium", "sodium"),
+ ("sodium_total", "sodium_total"),
+ ("potassium", "potassium"),
+ ("potassium_total", "potassium_total"),
+ ("sodium_plus_potassium", "sodium_plus_potassium"),
+ ("bicarbonate", "bicarbonate"),
+ ("carbonate", "carbonate"),
+ ("sulfate", "sulfate"),
+ ("chloride", "chloride"),
+ ("ion_balance", "ion_balance"),
+ ("total_anions", "total_anions"),
+ ("total_cations", "total_cations"),
+ ("alkalinity", "alkalinity"),
+ ("hardness", "hardness"),
+ ("specific_conductance", "specific_conductance"),
+ ("ph", "ph"),
+ ("nitrate", "nitrate"),
+ ("fluoride", "fluoride"),
+ ("silica", "silica"),
+]
+
+
+def _major_chemistry_select_columns() -> str:
+ return ",\n".join(
+ [
+ (
+ " MAX(lr.sample_value) FILTER "
+ f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}"
+ )
+ for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MAJOR
+ ]
+ )
+
+
+def _major_chemistry_unit_columns() -> str:
+ return ",\n".join(
+ [
+ (
+ " MAX(lr.units) FILTER "
+ f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units"
+ )
+ for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MAJOR
+ ]
+ )
+
+
+def _create_major_chemistry_results_view(public_only: bool) -> str:
+ static_columns = _major_chemistry_select_columns()
+ static_unit_columns = _major_chemistry_unit_columns()
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_major_chemistry_results AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ chemistry_rows AS (
+ SELECT
+ csi.thing_id,
+ mc.id AS result_id,
+ COALESCE(mc."AnalysisDate", csi."CollectionDate") AS observation_datetime,
+ trim(mc."Analyte") AS analyte_name,
+ trim(mc."Symbol") AS symbol_name,
+ mc."SampleValue"::double precision AS sample_value,
+ mc."Units" AS units
+ FROM "NMA_MajorChemistry" AS mc
+ JOIN "NMA_Chemistry_SampleInfo" AS csi
+ ON csi.id = mc.chemistry_sample_info_id
+ JOIN thing AS t
+ ON t.id = csi.thing_id
+ WHERE mc."SampleValue" IS NOT NULL
+ AND t.thing_type = 'water well'{release_filter}
+ ),
+ normalized_rows AS (
+ SELECT
+ cr.thing_id,
+ cr.result_id,
+ cr.observation_datetime,
+ NULLIF(
+ regexp_replace(
+ lower(trim(coalesce(cr.analyte_name, ''))),
+ '[^a-z0-9]+',
+ '',
+ 'g'
+ ),
+ ''
+ ) AS analyte_token,
+ NULLIF(
+ regexp_replace(
+ lower(trim(coalesce(cr.symbol_name, ''))),
+ '[^a-z0-9]+',
+ '',
+ 'g'
+ ),
+ ''
+ ) AS symbol_token,
+ cr.sample_value,
+ cr.units
+ FROM chemistry_rows AS cr
+ ),
+ mapped_rows AS (
+ SELECT
+ nr.thing_id,
+ nr.result_id,
+ nr.observation_datetime,
+ CASE
+ WHEN coalesce(nr.symbol_token, '') = 'tds'
+ OR coalesce(nr.analyte_token, '') IN ('tds', 'totaldissolvedsolids')
+ THEN 'tds'
+
+ WHEN coalesce(nr.symbol_token, '') = 'ca'
+ OR coalesce(nr.analyte_token, '') = 'ca'
+ THEN 'calcium'
+ WHEN coalesce(nr.analyte_token, '') = 'catotal'
+ THEN 'calcium_total'
+
+ WHEN coalesce(nr.symbol_token, '') = 'mg'
+ OR coalesce(nr.analyte_token, '') = 'mg'
+ THEN 'magnesium'
+ WHEN coalesce(nr.analyte_token, '') = 'mgtotal'
+ THEN 'magnesium_total'
+
+ WHEN coalesce(nr.symbol_token, '') = 'na'
+ OR coalesce(nr.analyte_token, '') = 'na'
+ THEN 'sodium'
+ WHEN coalesce(nr.analyte_token, '') = 'natotal'
+ THEN 'sodium_total'
+
+ WHEN coalesce(nr.symbol_token, '') = 'k'
+ OR coalesce(nr.analyte_token, '') = 'k'
+ THEN 'potassium'
+ WHEN coalesce(nr.analyte_token, '') = 'ktotal'
+ THEN 'potassium_total'
+
+ WHEN coalesce(nr.analyte_token, '') = 'nak'
+ THEN 'sodium_plus_potassium'
+
+ WHEN coalesce(nr.symbol_token, '') = 'hco3'
+ OR coalesce(nr.analyte_token, '') = 'hco3'
+ THEN 'bicarbonate'
+ WHEN coalesce(nr.symbol_token, '') = 'co3'
+ OR coalesce(nr.analyte_token, '') = 'co3'
+ THEN 'carbonate'
+ WHEN coalesce(nr.symbol_token, '') = 'so4'
+ OR coalesce(nr.analyte_token, '') = 'so4'
+ THEN 'sulfate'
+ WHEN coalesce(nr.symbol_token, '') = 'cl'
+ OR coalesce(nr.analyte_token, '') = 'cl'
+ THEN 'chloride'
+
+ WHEN coalesce(nr.analyte_token, '') = 'ionbal'
+ THEN 'ion_balance'
+ WHEN coalesce(nr.analyte_token, '') = 'tan'
+ THEN 'total_anions'
+ WHEN coalesce(nr.analyte_token, '') = 'tcat'
+ THEN 'total_cations'
+
+ WHEN coalesce(nr.analyte_token, '') IN ('alk', 'alkalinity')
+ THEN 'alkalinity'
+ WHEN coalesce(nr.analyte_token, '') IN ('hrd', 'hardness')
+ THEN 'hardness'
+ WHEN coalesce(nr.analyte_token, '') IN (
+ 'condlab',
+ 'specificconductance',
+ 'specificconductivity',
+ 'conductivity'
+ )
+ THEN 'specific_conductance'
+ WHEN coalesce(nr.symbol_token, '') = 'ph'
+ OR coalesce(nr.analyte_token, '') IN ('ph', 'phl')
+ THEN 'ph'
+
+ WHEN coalesce(nr.symbol_token, '') = 'no3'
+ OR coalesce(nr.analyte_token, '') IN ('no3', 'nitrate')
+ THEN 'nitrate'
+ WHEN coalesce(nr.symbol_token, '') = 'f'
+ OR coalesce(nr.analyte_token, '') IN ('f', 'fluoride')
+ THEN 'fluoride'
+ WHEN coalesce(nr.symbol_token, '') = 'sio2'
+ OR coalesce(nr.analyte_token, '') IN ('sio2', 'silica')
+ THEN 'silica'
+
+ ELSE NULL
+ END AS analyte_key,
+ nr.sample_value,
+ nr.units
+ FROM normalized_rows AS nr
+ ),
+ latest_results AS (
+ SELECT
+ mr.thing_id,
+ mr.analyte_key,
+ mr.sample_value,
+ mr.units,
+ mr.observation_datetime,
+ ROW_NUMBER() OVER (
+ PARTITION BY mr.thing_id, mr.analyte_key
+ ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC
+ ) AS rn
+ FROM mapped_rows AS mr
+ WHERE mr.analyte_key IS NOT NULL
+ )
+ SELECT
+ t.id AS id,
+ ll.location_id,
+ t.name,
+ t.thing_type,
+ COUNT(*)::integer AS analyte_count,
+ MAX(lr.observation_datetime::date) AS latest_chemistry_date,
+{static_columns},
+{static_unit_columns},
+ l.point
+ FROM latest_results AS lr
+ JOIN thing AS t ON t.id = lr.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE lr.rn = 1
+ GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point
+ """
+
+
+STATIC_ANALYTE_COLUMNS_MINOR: list[tuple[str, str]] = [
+ ("h2r", "h2r"),
+ ("o18r", "o18r"),
+ ("c13r", "c13r"),
+ ("c14", "c14"),
+ ("c14_years", "c14_years"),
+ ("fluoride", "fluoride"),
+ ("barium", "barium"),
+ ("barium_total", "barium_total"),
+ ("copper", "copper"),
+ ("copper_total", "copper_total"),
+ ("zinc", "zinc"),
+ ("zinc_total", "zinc_total"),
+ ("molybdenum", "molybdenum"),
+ ("molybdenum_total", "molybdenum_total"),
+ ("silica", "silica"),
+ ("silicon", "silicon"),
+ ("silicon_total", "silicon_total"),
+ ("manganese", "manganese"),
+ ("manganese_total", "manganese_total"),
+ ("iron", "iron"),
+ ("iron_total", "iron_total"),
+ ("strontium", "strontium"),
+ ("strontium_total", "strontium_total"),
+ ("chromium", "chromium"),
+ ("chromium_total", "chromium_total"),
+ ("boron", "boron"),
+ ("boron_total", "boron_total"),
+ ("uranium", "uranium"),
+ ("uranium_total", "uranium_total"),
+ ("lithium", "lithium"),
+ ("lithium_total", "lithium_total"),
+ ("silver", "silver"),
+ ("silver_total", "silver_total"),
+ ("antimony", "antimony"),
+ ("antimony_total", "antimony_total"),
+ ("beryllium", "beryllium"),
+ ("beryllium_total", "beryllium_total"),
+ ("lead", "lead"),
+ ("lead_total", "lead_total"),
+ ("thallium", "thallium"),
+ ("thallium_total", "thallium_total"),
+ ("bromide", "bromide"),
+ ("selenium", "selenium"),
+ ("selenium_total", "selenium_total"),
+ ("vanadium", "vanadium"),
+ ("vanadium_total", "vanadium_total"),
+ ("aluminum", "aluminum"),
+ ("aluminum_total", "aluminum_total"),
+ ("arsenic", "arsenic"),
+ ("arsenic_total", "arsenic_total"),
+ ("nickel", "nickel"),
+ ("nickel_total", "nickel_total"),
+ ("cadmium", "cadmium"),
+ ("cadmium_total", "cadmium_total"),
+ ("cobalt", "cobalt"),
+ ("cobalt_total", "cobalt_total"),
+ ("phosphate", "phosphate"),
+ ("nitrite", "nitrite"),
+ ("nitrate", "nitrate"),
+ ("nitrate_as_n", "nitrate_as_n"),
+ ("thorium", "thorium"),
+ ("thorium_total", "thorium_total"),
+ ("tin", "tin"),
+ ("tin_total", "tin_total"),
+ ("mercury", "mercury"),
+ ("mercury_total", "mercury_total"),
+ ("titanium", "titanium"),
+ ("titanium_total", "titanium_total"),
+]
+
+
+def _minor_chemistry_value_columns() -> str:
+ return ",\n".join(
+ [
+ (
+ " MAX(lr.sample_value) FILTER "
+ f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}"
+ )
+ for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MINOR
+ ]
+ )
+
+
+def _minor_chemistry_unit_columns() -> str:
+ return ",\n".join(
+ [
+ (
+ " MAX(lr.units) FILTER "
+ f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units"
+ )
+ for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MINOR
+ ]
+ )
+
+
+def _create_minor_chemistry_wells_view(public_only: bool) -> str:
+ value_columns = _minor_chemistry_value_columns()
+ unit_columns = _minor_chemistry_unit_columns()
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_minor_chemistry_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ chemistry_rows AS (
+ SELECT
+ csi.thing_id,
+ mtc.id AS result_id,
+ COALESCE(mtc.analysis_date::timestamp, csi."CollectionDate") AS observation_datetime,
+ trim(mtc.analyte) AS analyte_name,
+ mtc.sample_value::double precision AS sample_value,
+ mtc.units AS units
+ FROM "NMA_MinorTraceChemistry" AS mtc
+ JOIN "NMA_Chemistry_SampleInfo" AS csi
+ ON csi.id = mtc.chemistry_sample_info_id
+ JOIN thing AS t ON t.id = csi.thing_id
+ WHERE
+ mtc.sample_value IS NOT NULL
+ AND t.thing_type = 'water well'{release_filter}
+ ),
+ normalized_rows AS (
+ SELECT
+ cr.thing_id,
+ cr.result_id,
+ cr.observation_datetime,
+ NULLIF(
+ regexp_replace(
+ lower(trim(coalesce(cr.analyte_name, ''))),
+ '[^a-z0-9]+',
+ '',
+ 'g'
+ ),
+ ''
+ ) AS analyte_token,
+ cr.sample_value,
+ cr.units
+ FROM chemistry_rows AS cr
+ ),
+ mapped_rows AS (
+ SELECT
+ nr.thing_id,
+ nr.result_id,
+ nr.observation_datetime,
+ CASE
+ WHEN coalesce(nr.analyte_token, '') = 'h2r' THEN 'h2r'
+ WHEN coalesce(nr.analyte_token, '') = 'o18r' THEN 'o18r'
+ WHEN coalesce(nr.analyte_token, '') = 'c13r' THEN 'c13r'
+ WHEN coalesce(nr.analyte_token, '') = 'c14' THEN 'c14'
+ WHEN coalesce(nr.analyte_token, '') = 'c14years' THEN 'c14_years'
+
+ WHEN coalesce(nr.analyte_token, '') = 'f' THEN 'fluoride'
+ WHEN coalesce(nr.analyte_token, '') = 'ba' THEN 'barium'
+ WHEN coalesce(nr.analyte_token, '') = 'batotal' THEN 'barium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'cu' THEN 'copper'
+ WHEN coalesce(nr.analyte_token, '') = 'cutotal' THEN 'copper_total'
+ WHEN coalesce(nr.analyte_token, '') = 'zn' THEN 'zinc'
+ WHEN coalesce(nr.analyte_token, '') = 'zntotal' THEN 'zinc_total'
+ WHEN coalesce(nr.analyte_token, '') = 'mo' THEN 'molybdenum'
+ WHEN coalesce(nr.analyte_token, '') = 'mototal' THEN 'molybdenum_total'
+ WHEN coalesce(nr.analyte_token, '') = 'sio2' THEN 'silica'
+ WHEN coalesce(nr.analyte_token, '') = 'si' THEN 'silicon'
+ WHEN coalesce(nr.analyte_token, '') = 'sitotal' THEN 'silicon_total'
+ WHEN coalesce(nr.analyte_token, '') = 'mn' THEN 'manganese'
+ WHEN coalesce(nr.analyte_token, '') = 'mntotal' THEN 'manganese_total'
+ WHEN coalesce(nr.analyte_token, '') = 'fe' THEN 'iron'
+ WHEN coalesce(nr.analyte_token, '') = 'fetotal' THEN 'iron_total'
+ WHEN coalesce(nr.analyte_token, '') = 'sr' THEN 'strontium'
+ WHEN coalesce(nr.analyte_token, '') = 'srtotal' THEN 'strontium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'cr' THEN 'chromium'
+ WHEN coalesce(nr.analyte_token, '') = 'crtotal' THEN 'chromium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'b' THEN 'boron'
+ WHEN coalesce(nr.analyte_token, '') = 'btotal' THEN 'boron_total'
+ WHEN coalesce(nr.analyte_token, '') = 'u' THEN 'uranium'
+ WHEN coalesce(nr.analyte_token, '') = 'utotal' THEN 'uranium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'li' THEN 'lithium'
+ WHEN coalesce(nr.analyte_token, '') = 'litotal' THEN 'lithium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'ag' THEN 'silver'
+ WHEN coalesce(nr.analyte_token, '') = 'agtotal' THEN 'silver_total'
+ WHEN coalesce(nr.analyte_token, '') = 'sb' THEN 'antimony'
+ WHEN coalesce(nr.analyte_token, '') = 'sbtotal' THEN 'antimony_total'
+ WHEN coalesce(nr.analyte_token, '') = 'be' THEN 'beryllium'
+ WHEN coalesce(nr.analyte_token, '') = 'betotal' THEN 'beryllium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'pb' THEN 'lead'
+ WHEN coalesce(nr.analyte_token, '') = 'pbtotal' THEN 'lead_total'
+ WHEN coalesce(nr.analyte_token, '') = 'tl' THEN 'thallium'
+ WHEN coalesce(nr.analyte_token, '') = 'tltotal' THEN 'thallium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'br' THEN 'bromide'
+ WHEN coalesce(nr.analyte_token, '') = 'se' THEN 'selenium'
+ WHEN coalesce(nr.analyte_token, '') = 'setotal' THEN 'selenium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'v' THEN 'vanadium'
+ WHEN coalesce(nr.analyte_token, '') = 'vtotal' THEN 'vanadium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'al' THEN 'aluminum'
+ WHEN coalesce(nr.analyte_token, '') = 'altotal' THEN 'aluminum_total'
+ WHEN coalesce(nr.analyte_token, '') = 'as' THEN 'arsenic'
+ WHEN coalesce(nr.analyte_token, '') = 'astotal' THEN 'arsenic_total'
+ WHEN coalesce(nr.analyte_token, '') = 'ni' THEN 'nickel'
+ WHEN coalesce(nr.analyte_token, '') = 'nitotal' THEN 'nickel_total'
+ WHEN coalesce(nr.analyte_token, '') = 'cd' THEN 'cadmium'
+ WHEN coalesce(nr.analyte_token, '') = 'cdtotal' THEN 'cadmium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'co' THEN 'cobalt'
+ WHEN coalesce(nr.analyte_token, '') = 'cototal' THEN 'cobalt_total'
+ WHEN coalesce(nr.analyte_token, '') = 'po4' THEN 'phosphate'
+ WHEN coalesce(nr.analyte_token, '') = 'no2' THEN 'nitrite'
+ WHEN coalesce(nr.analyte_token, '') = 'no3' THEN 'nitrate'
+ WHEN coalesce(nr.analyte_token, '') = 'no3n' THEN 'nitrate_as_n'
+ WHEN coalesce(nr.analyte_token, '') = 'th' THEN 'thorium'
+ WHEN coalesce(nr.analyte_token, '') = 'thtotal' THEN 'thorium_total'
+ WHEN coalesce(nr.analyte_token, '') = 'sn' THEN 'tin'
+ WHEN coalesce(nr.analyte_token, '') = 'sntotal' THEN 'tin_total'
+ WHEN coalesce(nr.analyte_token, '') = 'hg' THEN 'mercury'
+ WHEN coalesce(nr.analyte_token, '') = 'hgtotal' THEN 'mercury_total'
+ WHEN coalesce(nr.analyte_token, '') = 'ti' THEN 'titanium'
+ WHEN coalesce(nr.analyte_token, '') = 'titotal' THEN 'titanium_total'
+ ELSE NULL
+ END AS analyte_key,
+ nr.sample_value,
+ nr.units
+ FROM normalized_rows AS nr
+ ),
+ latest_results AS (
+ SELECT
+ mr.thing_id,
+ mr.analyte_key,
+ mr.sample_value,
+ mr.units,
+ mr.observation_datetime,
+ ROW_NUMBER() OVER (
+ PARTITION BY mr.thing_id, mr.analyte_key
+ ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC
+ ) AS rn
+ FROM mapped_rows AS mr
+ WHERE mr.analyte_key IS NOT NULL
+ )
+ SELECT
+ t.id AS id,
+ ll.location_id,
+ t.name,
+ t.thing_type,
+ COUNT(*)::integer AS analyte_count,
+ MAX(lr.observation_datetime::date) AS latest_chemistry_date,
+{value_columns},
+{unit_columns},
+ l.point
+ FROM latest_results AS lr
+ JOIN thing AS t ON t.id = lr.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE lr.rn = 1
+ AND t.thing_type = 'water well'
+ GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point
+ """
+
+
+METERS_TO_FEET = 3.28084
+
+
+def _create_water_elevation_view(public_only: bool) -> str:
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE MATERIALIZED VIEW ogc_water_elevation_wells AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ ranked_obs AS (
+ SELECT
+ fe.thing_id,
+ o.id AS observation_id,
+ o.observation_datetime,
+ CASE
+ WHEN lower(trim(o.unit)) IN ('m', 'meter', 'meters', 'metre', 'metres') THEN
+ (o.value * {METERS_TO_FEET}) - COALESCE(o.measuring_point_height, 0)
+ WHEN lower(trim(o.unit)) IN ('ft', 'foot', 'feet') THEN
+ o.value - COALESCE(o.measuring_point_height, 0)
+ ELSE
+ NULL
+ END AS depth_to_water_below_ground_surface
+ FROM observation AS o
+ JOIN sample AS s ON s.id = o.sample_id
+ JOIN field_activity AS fa ON fa.id = s.field_activity_id
+ JOIN field_event AS fe ON fe.id = fa.field_event_id
+ JOIN thing AS t ON t.id = fe.thing_id
+ WHERE
+ t.thing_type = 'water well'
+ AND fa.activity_type = 'groundwater level'
+ AND o.value IS NOT NULL
+ AND o.observation_datetime IS NOT NULL
+ AND lower(trim(o.unit)) IN (
+ 'm',
+ 'meter',
+ 'meters',
+ 'metre',
+ 'metres',
+ 'ft',
+ 'foot',
+ 'feet'
+ ){release_filter}
+ ),
+ latest_obs AS (
+ SELECT
+ ro.*,
+ ROW_NUMBER() OVER (
+ PARTITION BY ro.thing_id
+ ORDER BY ro.observation_datetime DESC, ro.observation_id DESC
+ ) AS rn
+ FROM ranked_obs AS ro
+ )
+ SELECT
+ t.id AS id,
+ t.name,
+ t.thing_type,
+ lo.observation_id,
+ lo.observation_datetime,
+ l.elevation AS elevation_m,
+ lo.depth_to_water_below_ground_surface AS depth_to_water_below_ground_surface_ft,
+ ((l.elevation * {METERS_TO_FEET}) - lo.depth_to_water_below_ground_surface)
+ AS water_elevation_ft,
+ l.point
+ FROM latest_obs AS lo
+ JOIN thing AS t ON t.id = lo.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE lo.rn = 1
+ """
+
+
+def _create_actively_monitored_wells_view() -> str:
+ # No predicate of its own -- inherits public-only rows transitively via
+ # the JOIN to ogc_water_well_summary, which is itself filtered. Adding a
+ # filter on status_history.release_status here would repeat the mistake
+ # reverted in w1x2y3z4a5b6 (that column is never actually populated for
+ # this table).
+ return """
+ CREATE VIEW ogc_actively_monitored_wells AS
+ WITH latest_monitoring_status AS (
+ SELECT DISTINCT ON (sh.target_id)
+ sh.target_id AS thing_id,
+ sh.status_value
+ FROM status_history AS sh
+ WHERE
+ sh.target_table = 'thing'
+ AND sh.status_type = 'Monitoring Status'
+ ORDER BY sh.target_id, sh.start_date DESC, sh.id DESC
+ )
+ SELECT
+ wws.id,
+ wws.name,
+ 'water well'::text AS thing_type,
+ wws.well_depth,
+ wws.elevation,
+ wws.elevation_method,
+ wws.formation_zone,
+ wws.total_water_levels,
+ wws.last_water_level,
+ wws.last_water_level_datetime,
+ wws.min_water_level,
+ wws.max_water_level,
+ wws.water_level_trend_ft_per_year,
+ g.id AS group_id,
+ g.name AS group_name,
+ g.group_type,
+ wws.point
+ FROM "group" AS g
+ JOIN group_thing_association AS gta ON gta.group_id = g.id
+ JOIN ogc_water_well_summary AS wws ON wws.id = gta.thing_id
+ JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id
+ WHERE lower(trim(g.name)) = 'water level network'
+ AND lms.status_value = 'Currently monitored'
+ """
+
+
+def _create_project_areas_view(public_only: bool) -> str:
+ release_filter = " AND g.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE VIEW ogc_project_areas AS
+ SELECT
+ g.id,
+ g.name,
+ g.description,
+ g.group_type,
+ g.release_status,
+ g.project_area
+ FROM "group" AS g
+ WHERE g.project_area IS NOT NULL{release_filter}
+ """
+
+
+def _create_locations_view() -> str:
+ # Explicit column list verified against db/location.py and its mixins
+ # (AutoBaseMixin/AuditMixin, ReleaseMixin, NotesMixin, DataProvenanceMixin).
+ # NotesMixin/DataProvenanceMixin add only polymorphic relationships, no
+ # real columns. Audit columns (created_at, created_by_*, updated_by_*)
+ # are deliberately excluded, matching every other view in this file --
+ # none of them expose those columns either, even for Thing's otherwise
+ # thorough column list.
+ return """
+ CREATE VIEW ogc_locations AS
+ SELECT
+ l.id,
+ l.nma_pk_location,
+ l.description,
+ l.county,
+ l.state,
+ l.quad_name,
+ l.nma_location_notes,
+ l.nma_coordinate_notes,
+ l.nma_data_reliability,
+ l.nma_date_created,
+ l.nma_site_date,
+ l.release_status,
+ l.elevation,
+ l.point
+ FROM location AS l
+ WHERE l.release_status = 'public'
+ """
+
+
+def _recreate_governed_views(public_only: bool) -> None:
+ # ogc_actively_monitored_wells depends on ogc_water_well_summary via a
+ # direct JOIN; Postgres refuses to drop a materialized view while a
+ # dependent view exists, so it must go first and come back last.
+ _drop_view_or_materialized_view("ogc_actively_monitored_wells")
+
+ for view_id, thing_type in THING_VIEWS:
+ _drop_view_or_materialized_view(f"ogc_{_safe_view_id(view_id)}")
+ op.execute(text(_create_thing_view(view_id, thing_type, public_only)))
+
+ _drop_view_or_materialized_view("ogc_latest_depth_to_water_wells")
+ op.execute(text(_create_latest_depth_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_latest_depth_to_water_wells IS "
+ "'Latest depth-to-water per well view for pygeoapi.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_latest_depth_to_water_wells_id "
+ "ON ogc_latest_depth_to_water_wells (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_avg_tds_wells")
+ op.execute(text(_create_avg_tds_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_avg_tds_wells IS "
+ "'Average TDS per well from major chemistry results for pygeoapi.'"
+ )
+ )
+ op.execute(
+ text("CREATE UNIQUE INDEX ux_ogc_avg_tds_wells_id " "ON ogc_avg_tds_wells (id)")
+ )
+
+ _drop_view_or_materialized_view("ogc_latest_tds_wells")
+ op.execute(text(_create_latest_tds_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_latest_tds_wells IS "
+ "'Latest TDS per well from major chemistry results for pygeoapi.'"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_depth_to_water_trend_wells")
+ op.execute(text(_create_depth_to_water_trend_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_depth_to_water_trend_wells IS "
+ "'Depth-to-water trend classification for water wells.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_depth_to_water_trend_wells_id "
+ "ON ogc_depth_to_water_trend_wells (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_water_well_summary")
+ op.execute(text(_create_water_well_summary_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_water_well_summary IS "
+ "'Summary statistics for water wells including water-level trend.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_water_well_summary_id "
+ "ON ogc_water_well_summary (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_major_chemistry_results")
+ op.execute(text(_create_major_chemistry_results_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_major_chemistry_results IS "
+ "'Latest major-chemistry analyte values per location, pivoted into static analyte columns.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_major_chemistry_results_id "
+ "ON ogc_major_chemistry_results (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_minor_chemistry_wells")
+ op.execute(text(_create_minor_chemistry_wells_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_minor_chemistry_wells IS "
+ "'Latest minor/trace chemistry analyte values for water wells, pivoted into static analyte columns.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_minor_chemistry_wells_id "
+ "ON ogc_minor_chemistry_wells (id)"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_water_elevation_wells")
+ op.execute(text(_create_water_elevation_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON MATERIALIZED VIEW ogc_water_elevation_wells IS "
+ "'Latest water elevation per well with explicit units: "
+ "elevation_m, depth_to_water_below_ground_surface_ft, water_elevation_ft.'"
+ )
+ )
+ op.execute(
+ text(
+ "CREATE UNIQUE INDEX ux_ogc_water_elevation_wells_id "
+ "ON ogc_water_elevation_wells (id)"
+ )
+ )
+
+ # Recreate now that ogc_water_well_summary exists again.
+ op.execute(text(_create_actively_monitored_wells_view()))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_actively_monitored_wells IS "
+ "'Wells in the Water Level Network group for pygeoapi.'"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_project_areas")
+ op.execute(text(_create_project_areas_view(public_only)))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_project_areas IS "
+ "'Project areas for groups with polygon boundaries for pygeoapi.'"
+ )
+ )
+
+
+def upgrade() -> None:
+ _check_required_tables()
+ _recreate_governed_views(public_only=True)
+
+ # ogc_locations does not exist before this migration -- see module
+ # docstring. Only ever created in its filtered form.
+ _drop_view_or_materialized_view("ogc_locations")
+ op.execute(text(_create_locations_view()))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_locations IS "
+ "'Public locations for pygeoapi, replacing the raw location table provider.'"
+ )
+ )
+
+
+def downgrade() -> None:
+ _recreate_governed_views(public_only=False)
+
+ # ogc_locations never existed unfiltered in production; downgrading
+ # drops it rather than recreating an unfiltered copy.
+ _drop_view_or_materialized_view("ogc_locations")
diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml
index 45f3bac17..ccae84eab 100644
--- a/core/pygeoapi-config.yml
+++ b/core/pygeoapi-config.yml
@@ -54,7 +54,7 @@ resources:
password: {postgres_password_env}
search_path: [public]
id_field: id
- table: location
+ table: ogc_locations
geom_field: point
latest_depth_to_water_wells:
From 70c688f49528542c6eded1974a9f4cfaae8eb2cd Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Wed, 15 Jul 2026 14:38:02 -0600
Subject: [PATCH 026/151] feat(data-migrations): publish existing project_areas
ogc_project_areas is now filtered to public records, but all 56 current project_area-bearing group rows are release_status=draft (confirmed in docs/ogc-layer-audit.md), which would make the layer return zero rows until someone flips them.
Uses the data_migrations framework (not Alembic, which is schema-only in this repo) so the change is tracked, skip-if-applied, and gated on this ticket's schema revision having landed first. Runs independently via `oco data-migrations run 20260714_0001_publish_project_areas` -- deliberately not swept in via run-all, which could also apply other unrelated pending migrations. No downgrade: this framework is forward-only, and a blanket revert couldn't tell rows this migration published apart from ones published independently afterward -- undoing it later means writing a new, deliberate migration instead.
This is a genuine publication decision, not a mechanical schema change -- confirm the 56 rows are actually appropriate for public release before merging.
---
.../20260714_0001_publish_project_areas.py | 44 +++++++++++++++++++
tests/test_data_migrations.py | 34 ++++++++++++++
2 files changed, 78 insertions(+)
create mode 100644 data_migrations/migrations/20260714_0001_publish_project_areas.py
diff --git a/data_migrations/migrations/20260714_0001_publish_project_areas.py b/data_migrations/migrations/20260714_0001_publish_project_areas.py
new file mode 100644
index 000000000..244231667
--- /dev/null
+++ b/data_migrations/migrations/20260714_0001_publish_project_areas.py
@@ -0,0 +1,44 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+from sqlalchemy import update
+from sqlalchemy.orm import Session
+
+from data_migrations.base import DataMigration
+from db.group import Group
+
+
+def run(session: Session) -> None:
+ session.execute(
+ update(Group)
+ .where(Group.project_area.isnot(None))
+ .values(release_status="public")
+ )
+ session.commit()
+
+
+MIGRATION = DataMigration(
+ id="20260714_0001_publish_project_areas",
+ alembic_revision="f4a5b6c7d8e9",
+ name="Publish all project_areas records",
+ description=(
+ "Marks every group record backing the project_areas OGC layer "
+ "(project_area IS NOT NULL) as release_status='public'. Confirmed "
+ "via docs/ogc-layer-audit.md that all 56 current rows are "
+ "release_status='draft'."
+ ),
+ run=run,
+ is_repeatable=False,
+)
diff --git a/tests/test_data_migrations.py b/tests/test_data_migrations.py
index 3b0ce5211..8c11177d0 100644
--- a/tests/test_data_migrations.py
+++ b/tests/test_data_migrations.py
@@ -20,8 +20,12 @@
move_notes = importlib.import_module(
"data_migrations.migrations.20260205_0001_move_nma_location_notes"
)
+publish_project_areas = importlib.import_module(
+ "data_migrations.migrations.20260714_0001_publish_project_areas"
+)
from db.location import Location
from db.notes import Notes
+from db.group import Group
from db.engine import session_ctx
@@ -105,3 +109,33 @@ def test_move_nma_location_notes_skips_duplicates():
session.delete(notes[0])
session.delete(location)
session.commit()
+
+
+def test_publish_project_areas_marks_project_area_groups_public():
+ with session_ctx() as session:
+ draft_with_area = Group(
+ name="Draft Project Area A",
+ description="Has a project area, should be published.",
+ release_status="draft",
+ project_area="MULTIPOLYGON(((-107.2 33.6, -106.6 33.6, -106.6 34.2, -107.2 34.2, -107.2 33.6)))",
+ )
+ draft_without_area = Group(
+ name="Draft No Area",
+ description="No project area, should be left alone.",
+ release_status="draft",
+ )
+ session.add_all([draft_with_area, draft_without_area])
+ session.commit()
+ session.refresh(draft_with_area)
+ session.refresh(draft_without_area)
+
+ publish_project_areas.run(session)
+
+ session.refresh(draft_with_area)
+ session.refresh(draft_without_area)
+ assert draft_with_area.release_status == "public"
+ assert draft_without_area.release_status == "draft"
+
+ session.delete(draft_with_area)
+ session.delete(draft_without_area)
+ session.commit()
From 02f8cd35a09978fa64e9017a9c6b7f65f85b0868 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Wed, 15 Jul 2026 14:58:56 -0600
Subject: [PATCH 027/151] test(ogc): add behave coverage for public-only filter
Covers the migration-application, downgrade-reversibility, already-consistent-layers, and project_areas scenarios in ogc-cleanup-sprint1.feature tagged @A1. The other ~10 tickets sharing that feature file have no steps yet and stay undefined, per plan.
Tags @production only on these specific scenarios (the feature file has no feature-level tag since ~20 other tickets' scenarios share it) and @migration-mutates-schema on the three that call alembic downgrade, wiring a before/after_scenario hook in environment.py that unconditionally restores head so a downgrade left mid-scenario can't leak into later scenarios sharing this database.
---
tests/features/environment.py | 11 +-
tests/features/ogc-cleanup-sprint1.feature | 19 +-
tests/features/steps/ogc-cleanup-sprint1.py | 672 ++++++++++++++++++++
3 files changed, 695 insertions(+), 7 deletions(-)
create mode 100644 tests/features/steps/ogc-cleanup-sprint1.py
diff --git a/tests/features/environment.py b/tests/features/environment.py
index 340d087af..4d0f69034 100644
--- a/tests/features/environment.py
+++ b/tests/features/environment.py
@@ -832,10 +832,19 @@ def after_all(context):
def before_scenario(context, scenario):
# runs before EVERY scenario
# e.g. reset test data, open browser, etc.
- pass
+ if "migration-mutates-schema" in scenario.tags:
+ # Defense in depth against a previous, unrelated failure having
+ # already left the database below head.
+ command.upgrade(_alembic_config(), "head")
def after_scenario(context, scenario):
+ if "migration-mutates-schema" in scenario.tags:
+ # Runs whether the scenario passed or failed, so a downgrade left
+ # mid-scenario never leaks into later scenarios/features sharing
+ # this database. Deliberately not gated on DROP_AND_REBUILD_DB,
+ # since these scenarios mutate schema regardless of that flag.
+ command.upgrade(_alembic_config(), "head")
if not get_bool_env("DROP_AND_REBUILD_DB"):
return
diff --git a/tests/features/ogc-cleanup-sprint1.feature b/tests/features/ogc-cleanup-sprint1.feature
index 2049023bc..c510bb0fc 100644
--- a/tests/features/ogc-cleanup-sprint1.feature
+++ b/tests/features/ogc-cleanup-sprint1.feature
@@ -25,13 +25,13 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
# A1 — Apply release_status = 'public' filter to all OGC views
# ---------------------------------------------------------------------------
- @backend @ogc-exposure @sprint-1 @high-priority @A1
+ @backend @ogc-exposure @sprint-1 @high-priority @A1 @production @migration-mutates-schema @cleanup_samples
Scenario: Sprint 1 migration restricts all ogc_* views to public records
Given a clean database state before the Sprint 1 migration
When the Sprint 1 Alembic migration is applied
Then each ogc_* view returns only records with release_status "public"
- @backend @ogc-exposure @sprint-1 @high-priority @A1
+ @backend @ogc-exposure @sprint-1 @high-priority @A1 @production @migration-mutates-schema @cleanup_samples
Scenario: Sprint 1 migration can be reversed without error
Given the Sprint 1 migration has been applied
When the Sprint 1 migration downgrade is run
@@ -40,7 +40,7 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
And each ogc_* view returns the same count of draft records as before the migration
And no database errors are raised
- @backend @ogc-exposure @sprint-1 @high-priority @A1
+ @backend @ogc-exposure @sprint-1 @high-priority @A1 @production @cleanup_samples
Scenario: Non-public records are excluded from every exposure-affected OGC layer
Given the Sprint 1 migration has been applied
When a public client requests items from each of the following layers:
@@ -69,15 +69,22 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
# other_things above: A1 must apply the filter to its view, but A18 removes
# other_things from the catalog — run this scenario before A18 is applied
- @backend @ogc-exposure @sprint-1 @high-priority @A1
+ @backend @ogc-exposure @sprint-1 @high-priority @A1 @production
Scenario: project_areas returns 56 rows after all records are updated to public
Given all 56 project_areas records have been updated from release_status "draft" to release_status "public"
When a client requests features from the project_areas layer
Then the response contains 56 features
And the response HTTP status is 200
And all returned features have release_status "public"
-
- @backend @ogc-exposure @sprint-1 @high-priority @A1
+ # The "Given" precondition above is satisfied by a dedicated data
+ # migration (not the schema migration that adds the release_status
+ # filter) -- see the project_areas data migration in this ticket's
+ # implementation. Automated tests verify the underlying property
+ # (every project_area-bearing group ends up public), not the literal
+ # count 56, which is specific to today's real production data and is
+ # spot-checked manually against the target environment instead.
+
+ @backend @ogc-exposure @sprint-1 @high-priority @A1 @production @migration-mutates-schema @cleanup_samples
Scenario: The 4 already-consistent layers are unaffected by the migration
Given the following layers were already filtering correctly before the migration:
| layer-id |
diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py
new file mode 100644
index 000000000..b88da1130
--- /dev/null
+++ b/tests/features/steps/ogc-cleanup-sprint1.py
@@ -0,0 +1,672 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Step definitions for A1 (public release_status filter on ogc_* views).
+
+Only the @A1-tagged scenarios in ogc-cleanup-sprint1.feature are implemented
+here. The other ~10 tickets sharing that feature file have no steps yet and
+stay undefined/dormant, per this ticket's plan.
+"""
+import importlib
+from datetime import date
+
+from alembic import command
+from behave import given, when, then
+from sqlalchemy import text
+
+from core.dependencies import (
+ viewer_function,
+ amp_viewer_function,
+ amp_editor_function,
+ admin_function,
+ amp_admin_function,
+)
+from starlette.testclient import TestClient
+
+from db import (
+ Location,
+ Thing,
+ LocationThingAssociation,
+ Group,
+ GroupThingAssociation,
+ StatusHistory,
+ Contact,
+ FieldEvent,
+ FieldEventParticipant,
+ FieldActivity,
+ Sample,
+ Observation,
+ Sensor,
+ NMA_Chemistry_SampleInfo,
+ NMA_MajorChemistry,
+ NMA_MinorTraceChemistry,
+)
+from db.engine import session_ctx
+from tests import get_parameter_id
+from tests.features.environment import _alembic_config
+
+# Revision immediately before this ticket's schema migration -- re-verify
+# with `alembic heads`/`alembic history` if this file is revisited later,
+# since new migrations may have landed since.
+PRE_A1_REVISION = "y3z4a5b6c7d8"
+
+# Maps every OGC layer-id used in this feature file's data tables to the
+# seed group whose known public/private/draft ids should appear or not
+# appear in that layer. The 9 derived/summary layers and
+# actively_monitored_wells all key off the same seeded water wells.
+LAYER_ID_TO_SEED_KEY = {
+ "water_wells": "water_wells",
+ "springs": "springs",
+ "perennial_streams": "perennial_streams",
+ "meteorological_stations": "meteorological_stations",
+ "diversions_surface_water": "diversions_surface_water",
+ "lakes_ponds_reservoirs": "lakes_ponds_reservoirs",
+ "other_things": "other_things",
+ "water_well_summary": "water_wells",
+ "depth_to_water_trend_wells": "water_wells",
+ "water_elevation_wells": "water_wells",
+ "major_chemistry_results": "water_wells",
+ "minor_chemistry_wells": "water_wells",
+ "latest_tds_wells": "water_wells",
+ "actively_monitored_wells": "water_wells",
+ "avg_tds_wells": "water_wells",
+ "latest_depth_to_water_wells": "water_wells",
+ "locations": "locations",
+ "project_areas": "project_areas",
+ "ephemeral_streams": "ephemeral_streams",
+ "rock_sample_locations": "rock_sample_locations",
+ "soil_gas_sample_locations": "soil_gas_sample_locations",
+ "outfalls_wastewater_return_flow": "outfalls_wastewater_return_flow",
+}
+
+# Same mapping, keyed by the underlying ogc_* relation name, for the
+# SQL-level scenarios (migration-application, downgrade-reversibility).
+VIEW_TO_SEED_KEY = {
+ f"ogc_{layer_id}": seed_key for layer_id, seed_key in LAYER_ID_TO_SEED_KEY.items()
+}
+
+# ogc_locations does not exist before A1 (core/pygeoapi-config.yml pointed
+# directly at the raw location table), so downgrade drops it rather than
+# recreating an unfiltered copy -- there is no "count before the migration"
+# to compare it against once downgraded.
+NOT_PRESENT_BEFORE_A1 = {"ogc_locations"}
+
+# Thing-type layers backed by the shared _create_thing_view template --
+# a simple Location + Thing pair is enough to exercise these.
+SIMPLE_THING_TYPE_LAYERS = [
+ ("springs", "spring"),
+ ("perennial_streams", "perennial stream"),
+ ("meteorological_stations", "meteorological station"),
+ ("diversions_surface_water", "diversion of surface water, etc."),
+ ("lakes_ponds_reservoirs", "lake, pond or reservoir"),
+ ("other_things", "other"),
+ ("ephemeral_streams", "ephemeral stream"),
+ ("rock_sample_locations", "rock sample location"),
+ ("soil_gas_sample_locations", "soil gas sample location"),
+ ("outfalls_wastewater_return_flow", "outfall of wastewater or return flow"),
+]
+
+# The feature file's "4 already-consistent layers" scenario: today's live
+# data for these thing types happens to be 100% public already.
+ALREADY_CONSISTENT_LAYER_IDS = {
+ "ephemeral_streams",
+ "rock_sample_locations",
+ "soil_gas_sample_locations",
+ "outfalls_wastewater_return_flow",
+}
+
+STATUSES = ("public", "private", "draft")
+
+
+@given("the Ocotillo API is running")
+def step_given_ocotillo_api_is_running(context):
+ from main import app
+
+ def override_authentication(default=True):
+ def closure():
+ return default
+
+ return closure
+
+ app.dependency_overrides[amp_admin_function] = override_authentication(
+ default={"name": "foobar", "sub": "1234567890"}
+ )
+ app.dependency_overrides[admin_function] = override_authentication(
+ default={"name": "foobar", "sub": "1234567890"}
+ )
+ app.dependency_overrides[amp_editor_function] = override_authentication(
+ default={"name": "foobar", "sub": "1234567890"}
+ )
+ app.dependency_overrides[amp_viewer_function] = override_authentication()
+ app.dependency_overrides[viewer_function] = override_authentication()
+
+ context.client = TestClient(app)
+ assert context.client is not None, "TestClient failed to initialize"
+
+
+def _seed_thing_with_location(session, thing_type, release_status, name):
+ location = Location(
+ point="POINT(-106.5 34.0)",
+ elevation=1500.0,
+ release_status="public",
+ )
+ session.add(location)
+ session.commit()
+
+ thing = Thing(
+ name=name,
+ first_visit_date="2023-01-01",
+ thing_type=thing_type,
+ release_status=release_status,
+ )
+ session.add(thing)
+ session.commit()
+
+ assoc = LocationThingAssociation(location=location, thing=thing)
+ assoc.effective_start = "2023-01-01T00:00:00Z"
+ session.add(assoc)
+ session.commit()
+ session.refresh(thing)
+ return thing
+
+
+def _seed_water_well(session, release_status, name, monitoring_group):
+ well = _seed_thing_with_location(session, "water well", release_status, name)
+ # well.id is used to keep every unique-constrained field below distinct
+ # across repeated _seed_all() calls within the same behave run (this
+ # helper runs once per A1 scenario, sharing one database).
+ uid = well.id
+
+ # Observation chain feeding ogc_latest_depth_to_water_wells,
+ # ogc_depth_to_water_trend_wells, ogc_water_well_summary,
+ # ogc_water_elevation_wells.
+ contact = Contact(
+ name=f"A1 Contact {uid}",
+ role="Owner",
+ contact_type="Primary",
+ organization="NMBGMR",
+ release_status="draft",
+ )
+ session.add(contact)
+ session.commit()
+
+ field_event = FieldEvent(
+ thing_id=well.id,
+ event_date="2025-01-01T00:00:00Z",
+ notes="A1 behave seed field event",
+ release_status="draft",
+ )
+ session.add(field_event)
+ session.commit()
+
+ participant = FieldEventParticipant(
+ field_event_id=field_event.id,
+ contact_id=contact.id,
+ participant_role="Lead",
+ )
+ session.add(participant)
+ session.commit()
+
+ field_activity = FieldActivity(
+ field_event_id=field_event.id,
+ activity_type="groundwater level",
+ notes="A1 behave seed field activity",
+ release_status="draft",
+ )
+ session.add(field_activity)
+ session.commit()
+
+ sample = Sample(
+ field_activity_id=field_activity.id,
+ field_event_participant_id=participant.id,
+ sample_date="2025-01-01T12:00:00Z",
+ sample_name=f"A1 sample {uid}",
+ sample_matrix="water",
+ sample_method="Steel-tape measurement",
+ qc_type="Normal",
+ depth_top=None,
+ depth_bottom=None,
+ notes="A1 behave seed sample",
+ release_status="draft",
+ )
+ session.add(sample)
+ session.commit()
+
+ sensor = Sensor(
+ name=f"A1 Sensor {uid}",
+ sensor_type="Pressure Transducer",
+ model="Model X",
+ serial_no=f"A1-SN-{uid}",
+ pcn_number=f"A1-PCN-{uid}",
+ owner_agency="NMBGMR",
+ sensor_status="In Service",
+ notes="A1 behave seed sensor",
+ release_status="draft",
+ )
+ session.add(sensor)
+ session.commit()
+
+ observation = Observation(
+ observation_datetime="2025-01-01T00:04:00Z",
+ sample_id=sample.id,
+ sensor_id=sensor.id,
+ parameter_id=get_parameter_id("groundwater level", "Field Parameter"),
+ release_status="draft",
+ value=10.0,
+ unit="ft",
+ measuring_point_height=5.0,
+ groundwater_level_reason="Water level not affected",
+ )
+ session.add(observation)
+ session.commit()
+
+ # Chemistry rows feeding ogc_avg_tds_wells, ogc_latest_tds_wells,
+ # ogc_major_chemistry_results, ogc_minor_chemistry_wells.
+ # nma_sample_point_id is varchar(10) -- keep it short.
+ sample_point_id = f"A1{uid}"[:10]
+ csi = NMA_Chemistry_SampleInfo(
+ thing_id=well.id,
+ nma_sample_point_id=sample_point_id,
+ collection_date="2025-01-02T10:00:00Z",
+ )
+ session.add(csi)
+ session.flush()
+
+ major = NMA_MajorChemistry(
+ chemistry_sample_info_id=csi.id,
+ analyte="Total Dissolved Solids",
+ symbol="TDS",
+ sample_value=500.0,
+ units="mg/L",
+ analysis_date=None,
+ )
+ session.add(major)
+
+ minor = NMA_MinorTraceChemistry(
+ chemistry_sample_info_id=csi.id,
+ nma_sample_point_id=sample_point_id,
+ analyte="F",
+ symbol="",
+ sample_value=1.0,
+ units="mg/L",
+ analysis_date=date(2025, 1, 2),
+ )
+ session.add(minor)
+ session.commit()
+
+ # Water Level Network membership feeding ogc_actively_monitored_wells.
+ group_assoc = GroupThingAssociation(group_id=monitoring_group.id, thing_id=well.id)
+ session.add(group_assoc)
+ status_history = StatusHistory(
+ status_type="Monitoring Status",
+ status_value="Currently monitored",
+ start_date=date(2024, 1, 1),
+ end_date=None,
+ reason="A1 behave seed status",
+ target_id=well.id,
+ target_table="thing",
+ )
+ session.add(status_history)
+ session.commit()
+
+ return well
+
+
+def _seed_all(session):
+ """Seed one public/private/draft row per relevant thing type, one
+ draft Group with a project_area, and one standalone Location per
+ status. Returns {seed_key: {"public": id, "private": id, "draft": id}}.
+ """
+ seed_ids = {}
+
+ monitoring_group = Group(
+ name="Water Level Network",
+ description="A1 behave seed monitoring group",
+ release_status="public",
+ )
+ session.add(monitoring_group)
+ session.commit()
+
+ for layer_id, thing_type in SIMPLE_THING_TYPE_LAYERS:
+ seed_ids[layer_id] = {}
+ for status in STATUSES:
+ thing = _seed_thing_with_location(
+ session, thing_type, status, f"A1 {layer_id} {status}"
+ )
+ seed_ids[layer_id][status] = thing.id
+
+ seed_ids["water_wells"] = {}
+ for status in STATUSES:
+ well = _seed_water_well(
+ session, status, f"A1 water well {status}", monitoring_group
+ )
+ seed_ids["water_wells"][status] = well.id
+
+ seed_ids["project_areas"] = {}
+ for status in STATUSES:
+ group = Group(
+ name=f"A1 project area {status}",
+ description="A1 behave seed project area group",
+ release_status=status,
+ project_area=(
+ "MULTIPOLYGON(((-107.2 33.6, -106.6 33.6, "
+ "-106.6 34.2, -107.2 34.2, -107.2 33.6)))"
+ ),
+ )
+ session.add(group)
+ session.commit()
+ seed_ids["project_areas"][status] = group.id
+
+ seed_ids["locations"] = {}
+ for status in STATUSES:
+ location = Location(
+ point="POINT(-106.0 34.5)",
+ elevation=1600.0,
+ release_status=status,
+ )
+ session.add(location)
+ session.commit()
+ seed_ids["locations"][status] = location.id
+
+ # Materialized views are snapshots, not live queries -- the newly
+ # seeded rows are invisible to them until refreshed.
+ session.execute(text("SELECT public.refresh_materialized_views()"))
+ session.commit()
+
+ return seed_ids
+
+
+def _teardown_a1_seed_data():
+ """Delete every row these scenarios seed, by naming convention.
+
+ Without this, Thing/Group rows from an earlier A1 scenario in the same
+ behave run leak into a later scenario's absolute feature-count checks
+ (e.g. the already-consistent-layers scenario). Registered via
+ context.add_cleanup so it runs after the scenario regardless of
+ pass/fail, without needing an environment.py hook.
+ """
+ with session_ctx() as session:
+ session.execute(text("DELETE FROM thing WHERE name LIKE 'A1 %'"))
+ session.execute(
+ text(
+ "DELETE FROM \"group\" WHERE name LIKE 'A1 %' OR name = 'Water Level Network'"
+ )
+ )
+ session.commit()
+
+
+def _ensure_head(context):
+ command.upgrade(_alembic_config(), "head")
+ with session_ctx() as session:
+ context.seed_ids = _seed_all(session)
+ context.add_cleanup(_teardown_a1_seed_data)
+
+
+@given("a clean database state before the Sprint 1 migration")
+def step_given_clean_database_state(context):
+ command.downgrade(_alembic_config(), PRE_A1_REVISION)
+ with session_ctx() as session:
+ context.seed_ids = _seed_all(session)
+ context.add_cleanup(_teardown_a1_seed_data)
+
+
+@when("the Sprint 1 Alembic migration is applied")
+def step_when_sprint1_alembic_migration_is_applied(context):
+ command.upgrade(_alembic_config(), "head")
+
+
+@then('each ogc_* view returns only records with release_status "public"')
+def step_then_views_are_public_only(context):
+ with session_ctx() as session:
+ for relation, seed_key in VIEW_TO_SEED_KEY.items():
+ ids = context.seed_ids[seed_key]
+ for status in ("private", "draft"):
+ count = session.execute(
+ text(f"SELECT COUNT(*) FROM {relation} WHERE id = :id"),
+ {"id": ids[status]},
+ ).scalar()
+ assert count == 0, (
+ f"{relation} exposed a {status} row (id={ids[status]}) "
+ "that should have been filtered out"
+ )
+ public_count = session.execute(
+ text(f"SELECT COUNT(*) FROM {relation} WHERE id = :id"),
+ {"id": ids["public"]},
+ ).scalar()
+ assert public_count == 1, f"{relation} is missing its public seed row"
+
+
+@given("the Sprint 1 migration has been applied")
+def step_given_sprint1_migration_has_been_applied(context):
+ _ensure_head(context)
+
+
+@when("the Sprint 1 migration downgrade is run")
+def step_when_sprint1_migration_downgrade_is_run(context):
+ context.downgrade_error = None
+ try:
+ command.downgrade(_alembic_config(), PRE_A1_REVISION)
+ except Exception as exc: # noqa: BLE001 -- surfaced via the next Then step
+ context.downgrade_error = exc
+
+
+@then(
+ "each ogc_* view returns the same count of {status} records as before the migration"
+)
+def step_then_same_count_as_before_migration(context, status):
+ assert (
+ context.downgrade_error is None
+ ), f"Downgrade raised an error before counts could be checked: {context.downgrade_error}"
+ with session_ctx() as session:
+ for relation, seed_key in VIEW_TO_SEED_KEY.items():
+ if relation in NOT_PRESENT_BEFORE_A1:
+ continue
+ ids = context.seed_ids[seed_key]
+ count = session.execute(
+ text(f"SELECT COUNT(*) FROM {relation} WHERE id = :id"),
+ {"id": ids[status]},
+ ).scalar()
+ assert count == 1, (
+ f"{relation}: expected the seeded {status} row to be visible again "
+ f"after downgrade (pre-A1 had no filter), got count={count}"
+ )
+
+
+@then("no database errors are raised")
+def step_then_no_database_errors_are_raised(context):
+ assert (
+ context.downgrade_error is None
+ ), f"Downgrade raised: {context.downgrade_error}"
+ # Restore head immediately so later scenarios/features never run against
+ # a downgraded schema even if a later step in this scenario fails.
+ command.upgrade(_alembic_config(), "head")
+
+
+def _get_items(context, layer_id, limit=200):
+ response = context.client.get(f"/ogcapi/collections/{layer_id}/items?limit={limit}")
+ assert (
+ response.status_code == 200
+ ), f"Unexpected status {response.status_code} for layer {layer_id}: {response.text}"
+ return response.json()
+
+
+@when("a public client requests items from each of the following layers:")
+def step_when_public_client_requests_items_from_layers(context):
+ context.layer_responses = {}
+ for row in context.table:
+ layer_id = row["layer-id"].strip()
+ context.layer_responses[layer_id] = _get_items(context, layer_id)
+
+
+def _layer_feature_ids(payload):
+ ids = set()
+ for feature in payload["features"]:
+ feature_id = feature.get("id", feature.get("properties", {}).get("id"))
+ ids.add(feature_id)
+ return ids
+
+
+@then('each response contains only records where release_status is "public"')
+def step_then_each_response_contains_only_public(context):
+ for layer_id, payload in context.layer_responses.items():
+ seed_key = LAYER_ID_TO_SEED_KEY[layer_id]
+ features = payload["features"]
+ if features and "release_status" in features[0]["properties"]:
+ for feature in features:
+ assert (
+ feature["properties"]["release_status"] == "public"
+ ), f"{layer_id} returned a non-public record: {feature['properties']}"
+ else:
+ ids_present = _layer_feature_ids(payload)
+ public_id = context.seed_ids[seed_key]["public"]
+ assert (
+ public_id in ids_present
+ ), f"{layer_id} is missing its public seed row"
+
+
+@then('no response contains a record where release_status is "{status}"')
+def step_then_no_response_contains_status(context, status):
+ for layer_id, payload in context.layer_responses.items():
+ seed_key = LAYER_ID_TO_SEED_KEY[layer_id]
+ features = payload["features"]
+ if features and "release_status" in features[0]["properties"]:
+ for feature in features:
+ assert (
+ feature["properties"]["release_status"] != status
+ ), f"{layer_id} returned a {status} record: {feature['properties']}"
+ else:
+ ids_present = _layer_feature_ids(payload)
+ excluded_id = context.seed_ids[seed_key][status]
+ assert (
+ excluded_id not in ids_present
+ ), f"{layer_id} exposed its seeded {status} row (id={excluded_id})"
+
+
+@given(
+ 'all 56 project_areas records have been updated from release_status "draft" to release_status "public"'
+)
+def step_given_56_project_areas_updated_to_public(context):
+ publish_project_areas = importlib.import_module(
+ "data_migrations.migrations.20260714_0001_publish_project_areas"
+ )
+ command.upgrade(_alembic_config(), "head")
+ with session_ctx() as session:
+ groups = [
+ Group(
+ name=f"A1 56-count project area {i}",
+ description="A1 behave seed project area for the 56-row scenario",
+ release_status="draft",
+ project_area=(
+ "MULTIPOLYGON(((-107.0 33.0, -106.9 33.0, "
+ "-106.9 33.1, -107.0 33.1, -107.0 33.0)))"
+ ),
+ )
+ for i in range(56)
+ ]
+ session.add_all(groups)
+ session.commit()
+ context.project_area_group_ids = [g.id for g in groups]
+
+ publish_project_areas.run(session)
+
+
+@when("a client requests features from the project_areas layer")
+def step_when_client_requests_project_areas(context):
+ context.response = context.client.get(
+ "/ogcapi/collections/project_areas/items?limit=1000"
+ )
+
+
+@then("the response contains {count:d} features")
+def step_then_response_contains_n_features(context, count):
+ payload = context.response.json()
+ matching = [
+ f
+ for f in payload["features"]
+ if f.get("id", f.get("properties", {}).get("id"))
+ in set(context.project_area_group_ids)
+ ]
+ assert len(matching) == count, (
+ f"Expected {count} of this scenario's seeded project_areas features, "
+ f"found {len(matching)}"
+ )
+
+
+@then("the response HTTP status is {status:d}")
+def step_then_response_http_status_is(context, status):
+ assert (
+ context.response.status_code == status
+ ), f"Unexpected status {context.response.status_code}, expected {status}"
+
+
+@then('all returned features have release_status "public"')
+def step_then_all_returned_features_are_public(context):
+ payload = context.response.json()
+ for feature in payload["features"]:
+ if feature.get("id", feature.get("properties", {}).get("id")) not in set(
+ context.project_area_group_ids
+ ):
+ continue
+ assert (
+ feature["properties"]["release_status"] == "public"
+ ), f"Feature {feature.get('id')} was not public: {feature['properties']}"
+
+
+def _seed_already_consistent_layers(session):
+ """These 4 layers are asserted to be 100% public today -- unlike
+ _seed_all(), only public rows are seeded here. Seeding private/draft
+ rows into them would defeat the point of this scenario: proving the
+ filter changes nothing because there was never anything to filter.
+ """
+ for layer_id, thing_type in SIMPLE_THING_TYPE_LAYERS:
+ if layer_id not in ALREADY_CONSISTENT_LAYER_IDS:
+ continue
+ for i in range(3):
+ _seed_thing_with_location(
+ session, thing_type, "public", f"A1 already-consistent {layer_id} {i}"
+ )
+
+
+@given("the following layers were already filtering correctly before the migration:")
+def step_given_already_consistent_layers(context):
+ command.downgrade(_alembic_config(), PRE_A1_REVISION)
+ with session_ctx() as session:
+ _seed_already_consistent_layers(session)
+ session.commit()
+ context.add_cleanup(_teardown_a1_seed_data)
+
+ context.already_consistent_counts = {}
+ for row in context.table:
+ layer_id = row["layer-id"].strip()
+ payload = _get_items(context, layer_id, limit=500)
+ context.already_consistent_counts[layer_id] = len(payload["features"])
+
+
+@when("the Sprint 1 migration is applied")
+def step_when_sprint1_migration_is_applied(context):
+ command.upgrade(_alembic_config(), "head")
+
+
+@then("each of those layers returns the same feature count as before the migration")
+def step_then_same_feature_count_as_before(context):
+ for layer_id, before_count in context.already_consistent_counts.items():
+ payload = _get_items(context, layer_id, limit=500)
+ after_count = len(payload["features"])
+ assert (
+ after_count == before_count
+ ), f"{layer_id}: expected {before_count} features (unchanged), got {after_count}"
+
+
+# ============= EOF =============================================
From c2860182f75bdb84094aec2df8afcd9df2a61607 Mon Sep 17 00:00:00 2001
From: ksmuczynski <20096455+ksmuczynski@users.noreply.github.com>
Date: Wed, 15 Jul 2026 22:55:14 +0000
Subject: [PATCH 028/151] Formatting changes
---
tests/features/steps/ogc-cleanup-sprint1.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py
index b88da1130..5c2f3e5ee 100644
--- a/tests/features/steps/ogc-cleanup-sprint1.py
+++ b/tests/features/steps/ogc-cleanup-sprint1.py
@@ -19,6 +19,7 @@
here. The other ~10 tickets sharing that feature file have no steps yet and
stay undefined/dormant, per this ticket's plan.
"""
+
import importlib
from datetime import date
From 8ae9fe1873f3c6e7bbd62ea9e8e08fa76bdae1db Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Mon, 3 Aug 2026 10:17:12 -0600
Subject: [PATCH 029/151] fix(ogc): re-point migration to new staging head
Several migrations (EDR water views, NMW_WellLocations dedupe, pg_cron reschedule) landed on staging off the same y3z4a5b6c7d8 branchpoint while this branch was still open, leaving f4a5b6c7d8e9 chained to a revision that was no longer the tip --`alembic heads` showed two heads instead of one.
Re-points down_revision to the new staging tip (b6c7d8e9f0a1). No content change needed: the two new EDR views already filter on release_status = 'public' themselves, and the other two migrations only touch the unrelated NMW measurement views and pg_cron scheduling.
Re-verified after the edit: single alembic head, full pytest suite, the downgrade/upgrade cycle at the new chain position, and behave --tags=@A1, all against a fully rebuilt local test database.
---
...pply_public_release_status_filter_to_ogc_views.py | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py b/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py
index 3cc644393..c5587e22f 100644
--- a/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py
+++ b/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py
@@ -23,8 +23,16 @@
unfiltered copy.
Revision ID: f4a5b6c7d8e9
-Revises: y3z4a5b6c7d8
+Revises: b6c7d8e9f0a1
Create Date: 2026-07-14 00:00:00.000000
+
+Re-pointed from the original y3z4a5b6c7d8 on 2026-08-03: three colleague
+migrations (z9a0b1c2d3e4, a5b6c7d8e9f0, b6c7d8e9f0a1) landed on staging off
+that same revision while this branch was still open, forking the history.
+Neither touches anything this migration's SQL depends on -- the two new EDR
+views (z9a0b1c2d3e4) already filter on release_status = 'public' themselves,
+and the other two only touch the unrelated NMW measurement views and
+pg_cron scheduling.
"""
import re
@@ -35,7 +43,7 @@
# revision identifiers, used by Alembic.
revision: str = "f4a5b6c7d8e9"
-down_revision: Union[str, Sequence[str], None] = "y3z4a5b6c7d8"
+down_revision: Union[str, Sequence[str], None] = "b6c7d8e9f0a1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
From 3882a5df77138a46e34fd53711284892e01a2408 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Mon, 3 Aug 2026 11:43:38 -0600
Subject: [PATCH 030/151] fix(ogc): drop duplicate collections step definition
Merging A1's updated branch (itself synced with a newer staging) brought in tests/features/steps/edr_water_data.py, which defines its own "a client requests /ogcapi/collections" step. Behave raises AmbiguousStep when identical step text is registered twice across files, so this step no longer defines it here, leaving the functionally identical one in edr_water_data.py as the sole definition.
---
tests/features/steps/ogc-cleanup-sprint1.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py
index 51e744d8a..2b27e539f 100644
--- a/tests/features/steps/ogc-cleanup-sprint1.py
+++ b/tests/features/steps/ogc-cleanup-sprint1.py
@@ -841,9 +841,11 @@ def step_then_no_internal_relation_shared_with_public(context):
)
-@when("a client requests /ogcapi/collections")
-def step_when_client_requests_ogcapi_collections(context):
- context.response = context.client.get("/ogcapi/collections")
+# "a client requests /ogcapi/collections" is defined in
+# tests/features/steps/edr_water_data.py (functionally identical: GETs
+# /ogcapi/collections and stashes context.response) -- reused rather than
+# redefined here, since Behave raises AmbiguousStep on duplicate step text
+# across files.
@then('no collection in the response has an id prefixed "{prefix}"')
From cc5f72cfec7318b9200cc2fd9c013a972ee4aa23 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Mon, 3 Aug 2026 11:45:06 -0600
Subject: [PATCH 031/151] feat(ogc): mirror EDR views in internal OGC mount
The merge that brought A1 up to date with staging also added ogc_waterlevels/ogc_water_chemistry (ADR3's EDR feature), which did not exist when A11's plan scoped this migration to 22 relations. Given the choice to leave them out or extend to match, chose to extend: A11's purpose is giving staff unfiltered access to what the public mount filters, and these two are filtered the same way as the original 22.
ogc_internal_waterlevels/ogc_internal_water_chemistry mirror the public views with all three release_status predicates dropped (both the manual-reading and chemistry selects, plus the transducer union). _edr_collections_block now takes table_prefix, threaded through the same include_edr flag that keeps EDR off the internal mount if it is ever wired wrong again.
Added a forward-looking parity test that diffs the public and internal relation sets directly, rather than checking a hardcoded count. It would have caught this exact gap, a new public view landing with no internal mirror, on its own instead of needing a human to notice.
---
.../2d3c3a268652_create_internal_ogc_views.py | 134 +++++++++++++++++-
core/pygeoapi.py | 20 ++-
tests/test_migration_view_parity.py | 69 +++++++++
3 files changed, 214 insertions(+), 9 deletions(-)
diff --git a/alembic/versions/2d3c3a268652_create_internal_ogc_views.py b/alembic/versions/2d3c3a268652_create_internal_ogc_views.py
index affda020d..a7e7c5835 100644
--- a/alembic/versions/2d3c3a268652_create_internal_ogc_views.py
+++ b/alembic/versions/2d3c3a268652_create_internal_ogc_views.py
@@ -6,6 +6,14 @@
(core/pygeoapi.py::mount_pygeoapi_internal). Full parity with the public
set, per ticket A11 -- not a subset.
+Also mirrors ogc_waterlevels/ogc_water_chemistry from z9a0b1c2d3e4 (added to
+staging after this migration's original 22-relation scope was written, per
+ADR3's EDR feature) as ogc_internal_waterlevels/ogc_internal_water_chemistry,
+bringing the total to 24. Unfiltered in all three places the public views
+predicate on release_status: the manual-readings and chemistry selects'
+`o.release_status = 'public'`, and the transducer union's
+`tobs.release_status = 'public'`.
+
The major/minor chemistry analyte-mapping CASE blocks and
STATIC_ANALYTE_COLUMNS lists below are intentionally character-for-character
identical (modulo view name) to their counterparts in f4a5b6c7d8e9 -- this
@@ -25,7 +33,7 @@
of that JOIN, it must be dropped before ogc_internal_water_well_summary and
recreated after (same ordering constraint as the public side).
-All 22 relations here are newly created by this migration -- none of them
+All 24 relations here are newly created by this migration -- none of them
existed in any form beforehand -- so downgrade() simply drops them rather
than recreating a prior state.
@@ -61,6 +69,11 @@
"NMA_MajorChemistry",
"NMA_Chemistry_SampleInfo",
"NMA_MinorTraceChemistry",
+ # For the ogc_internal_waterlevels/ogc_internal_water_chemistry EDR
+ # mirrors (see z9a0b1c2d3e4_add_edr_water_views.py).
+ "transducer_observation",
+ "deployment",
+ "parameter",
}
LATEST_LOCATION_CTE = """
@@ -1118,6 +1131,101 @@ def _create_locations_view() -> str:
"""
+# Shared join from a thing to its current location point -- same shape as
+# z9a0b1c2d3e4's _LOCATION_JOIN.
+_EDR_LOCATION_JOIN = """
+ JOIN location_thing_association lta
+ ON lta.thing_id = t.id AND lta.effective_end IS NULL
+ JOIN location l ON l.id = lta.location_id
+"""
+
+
+def _create_internal_waterlevels_view() -> str:
+ # Mirrors z9a0b1c2d3e4's ogc_waterlevels with both release_status
+ # predicates dropped (manual readings: o.release_status; transducer
+ # readings: tobs.release_status). release_status itself is still
+ # selected as a column, same as the public view.
+ return f"""
+ CREATE VIEW ogc_internal_waterlevels AS
+ -- manual water-level readings
+ SELECT
+ 'm-' || o.id AS id,
+ t.id AS thing_id,
+ t.name AS station_name,
+ ST_X(l.point) AS longitude,
+ ST_Y(l.point) AS latitude,
+ o.observation_datetime AS datetime,
+ o.value AS value,
+ o.unit AS unit,
+ 'groundwater level' AS parameter_name,
+ 'manual' AS source,
+ NULL::integer AS deployment_id,
+ o.release_status AS release_status
+ FROM observation o
+ JOIN parameter p
+ ON p.id = o.parameter_id AND p.parameter_name = 'groundwater level'
+ JOIN sample sm ON sm.id = o.sample_id
+ JOIN field_activity fa ON fa.id = sm.field_activity_id
+ JOIN field_event fe ON fe.id = fa.field_event_id
+ JOIN thing t ON t.id = fe.thing_id
+ {_EDR_LOCATION_JOIN}
+ WHERE o.value IS NOT NULL
+
+ UNION ALL
+
+ -- transducer (instrument) water-level readings
+ SELECT
+ 't-' || tobs.id AS id,
+ t.id AS thing_id,
+ t.name AS station_name,
+ ST_X(l.point) AS longitude,
+ ST_Y(l.point) AS latitude,
+ tobs.observation_datetime AS datetime,
+ tobs.value AS value,
+ p.default_unit AS unit,
+ 'groundwater level' AS parameter_name,
+ 'transducer' AS source,
+ tobs.deployment_id AS deployment_id,
+ tobs.release_status AS release_status
+ FROM transducer_observation tobs
+ JOIN parameter p
+ ON p.id = tobs.parameter_id AND p.parameter_name = 'groundwater level'
+ JOIN deployment d ON d.id = tobs.deployment_id
+ JOIN thing t ON t.id = d.thing_id
+ {_EDR_LOCATION_JOIN}
+ WHERE tobs.value IS NOT NULL
+ """
+
+
+def _create_internal_water_chemistry_view() -> str:
+ # Mirrors z9a0b1c2d3e4's ogc_water_chemistry with its release_status
+ # predicate (o.release_status) dropped.
+ return f"""
+ CREATE VIEW ogc_internal_water_chemistry AS
+ SELECT
+ 'c-' || o.id AS id,
+ t.id AS thing_id,
+ t.name AS station_name,
+ ST_X(l.point) AS longitude,
+ ST_Y(l.point) AS latitude,
+ o.observation_datetime AS datetime,
+ o.value AS value,
+ o.unit AS unit,
+ p.parameter_name AS parameter_name,
+ o.sample_id AS sample_id,
+ o.release_status AS release_status
+ FROM observation o
+ JOIN parameter p
+ ON p.id = o.parameter_id AND p.parameter_name <> 'groundwater level'
+ JOIN sample sm ON sm.id = o.sample_id
+ JOIN field_activity fa ON fa.id = sm.field_activity_id
+ JOIN field_event fe ON fe.id = fa.field_event_id
+ JOIN thing t ON t.id = fe.thing_id
+ {_EDR_LOCATION_JOIN}
+ WHERE o.value IS NOT NULL
+ """
+
+
def _recreate_all_internal_views() -> None:
# ogc_internal_actively_monitored_wells depends on
# ogc_internal_water_well_summary via a direct JOIN; Postgres refuses to
@@ -1271,8 +1379,26 @@ def _recreate_all_internal_views() -> None:
)
)
+ _drop_view_or_materialized_view("ogc_internal_waterlevels")
+ op.execute(text(_create_internal_waterlevels_view()))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_internal_waterlevels IS "
+ "'Unfiltered depth-to-water readings (manual + transducer) for the internal pygeoapi mount.'"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_internal_water_chemistry")
+ op.execute(text(_create_internal_water_chemistry_view()))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_internal_water_chemistry IS "
+ "'Unfiltered water-chemistry analyses (by analyte) for the internal pygeoapi mount.'"
+ )
+ )
+
-# All 22 relations this migration creates, in an order safe for DROP (the
+# All 24 relations this migration creates, in an order safe for DROP (the
# dependent view first, mirroring _recreate_all_internal_views's ordering).
ALL_INTERNAL_RELATIONS = [
"ogc_internal_actively_monitored_wells",
@@ -1287,6 +1413,8 @@ def _recreate_all_internal_views() -> None:
"ogc_internal_water_elevation_wells",
"ogc_internal_project_areas",
"ogc_internal_locations",
+ "ogc_internal_waterlevels",
+ "ogc_internal_water_chemistry",
]
@@ -1296,7 +1424,7 @@ def upgrade() -> None:
def downgrade() -> None:
- # None of these 22 relations existed before this migration -- unlike
+ # None of these 24 relations existed before this migration -- unlike
# f4a5b6c7d8e9's downgrade (which recreates the prior unfiltered public
# views), there is no prior state to restore, so downgrade just drops
# everything this migration created.
diff --git a/core/pygeoapi.py b/core/pygeoapi.py
index 2bea76daa..5d6503011 100644
--- a/core/pygeoapi.py
+++ b/core/pygeoapi.py
@@ -260,9 +260,15 @@ def _edr_collections_block(
dbname: str,
user: str,
password_placeholder: str,
+ table_prefix: str = "ogc_",
) -> str:
resources: dict[str, dict] = {}
for collection in EDR_COLLECTIONS:
+ # EDR_COLLECTIONS' table values are hardcoded to the public
+ # ogc_waterlevels/ogc_water_chemistry names -- strip that literal
+ # "ogc_" so table_prefix (here, "ogc_internal_" for the internal
+ # mount) still applies, the same way _thing_collections_block does.
+ table_name = table_prefix + collection["table"].removeprefix("ogc_")
provider = {
"type": "edr",
"name": "core.edr_provider.WaterEDRProvider",
@@ -274,7 +280,7 @@ def _edr_collections_block(
"password": password_placeholder,
},
"id_field": "id",
- "table": collection["table"],
+ "table": table_name,
}
if collection["instance_field"]:
provider["instance_field"] = collection["instance_field"]
@@ -354,11 +360,11 @@ def _write_config(
table_prefix=table_prefix,
)
if include_edr:
- # EDR collections (core/edr_provider.py) are backed by ogc_waterlevels/
- # ogc_water_chemistry, which are always public-filtered (see
- # z9a0b1c2d3e4_add_edr_water_views.py) with no table_prefix
- # parametrization -- there is no internal, unfiltered counterpart, so
- # this only ever applies to the public mount's config.
+ # EDR collections (core/edr_provider.py), backed by
+ # ogc_waterlevels/ogc_water_chemistry (public) or
+ # ogc_internal_waterlevels/ogc_internal_water_chemistry (internal,
+ # see 2d3c3a268652_create_internal_ogc_views.py) depending on
+ # table_prefix.
thing_collections_block = "\n".join(
[
thing_collections_block,
@@ -368,6 +374,7 @@ def _write_config(
dbname=dbname,
user=user,
password_placeholder=password_placeholder,
+ table_prefix=table_prefix,
),
]
)
@@ -495,6 +502,7 @@ def mount_pygeoapi_internal(app: FastAPI) -> None:
server_url=_internal_server_url(),
table_prefix="ogc_internal_",
template_path=_internal_template_path(),
+ include_edr=True,
)
_generate_openapi(config_path, openapi_path)
_assert_server_settings_match(_pygeoapi_dir() / "pygeoapi-config.yml", config_path)
diff --git a/tests/test_migration_view_parity.py b/tests/test_migration_view_parity.py
index 5c6035b80..65e109b1e 100644
--- a/tests/test_migration_view_parity.py
+++ b/tests/test_migration_view_parity.py
@@ -11,6 +11,7 @@
"""
import ast
+import re
from pathlib import Path
import pytest
@@ -20,6 +21,7 @@
VERSIONS_DIR / "f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py"
)
INTERNAL_MIGRATION = VERSIONS_DIR / "2d3c3a268652_create_internal_ogc_views.py"
+EDR_MIGRATION = VERSIONS_DIR / "z9a0b1c2d3e4_add_edr_water_views.py"
# Substrings that are expected to differ between the two files -- normalized
# away before comparison. Order matters: the internal_ variant must be
@@ -74,3 +76,70 @@ def test_analyte_mapping_matches_between_public_and_internal_migrations(name):
"(2d3c3a268652) OGC migrations -- if this is a genuine analyte-mapping "
"fix, apply it to both files."
)
+
+
+# ---------------------------------------------------------------------------
+# Forward-looking coverage check: does every public ogc_* relation (across
+# f4a5b6c7d8e9 and any later migration that adds more, like z9a0b1c2d3e4's
+# EDR views) have an ogc_internal_ mirror? This is exactly the gap that let
+# the EDR views land on staging with no internal counterpart in the first
+# place -- nothing caught it until a human noticed.
+# ---------------------------------------------------------------------------
+
+# Matches "CREATE VIEW ogc_x AS" / "CREATE MATERIALIZED VIEW ogc_internal_x
+# AS" for any *literal* relation name. Deliberately does not match the 11
+# thing-type views on either side of the parity: their names are built from
+# an f-string variable (ogc_{safe_view_id}), not a literal, in both files --
+# handled separately via THING_VIEWS below.
+_CREATE_VIEW_RE = re.compile(
+ r"CREATE (?:MATERIALIZED )?VIEW (ogc_(?:internal_)?[A-Za-z0-9_]+) AS"
+)
+
+
+def _literal_view_names(path: Path) -> set[str]:
+ return set(_CREATE_VIEW_RE.findall(path.read_text(encoding="utf-8")))
+
+
+def _thing_view_ids(path: Path) -> set[str]:
+ source = path.read_text(encoding="utf-8")
+ tree = ast.parse(source, filename=str(path))
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Assign):
+ targets = [t.id for t in node.targets if isinstance(t, ast.Name)]
+ if "THING_VIEWS" in targets and isinstance(node.value, ast.List):
+ return {
+ elt.elts[0].value
+ for elt in node.value.elts
+ if isinstance(elt, ast.Tuple)
+ }
+ raise AssertionError(f"THING_VIEWS not found in {path}")
+
+
+def test_internal_migration_mirrors_every_public_relation():
+ public_thing_ids = _thing_view_ids(PUBLIC_MIGRATION)
+ internal_thing_ids = _thing_view_ids(INTERNAL_MIGRATION)
+ assert public_thing_ids == internal_thing_ids, (
+ f"THING_VIEWS has drifted: public has {public_thing_ids}, "
+ f"internal has {internal_thing_ids}"
+ )
+
+ public_relation_ids = {
+ name.removeprefix("ogc_")
+ for name in _literal_view_names(PUBLIC_MIGRATION)
+ | _literal_view_names(EDR_MIGRATION)
+ } | public_thing_ids
+ internal_relation_ids = {
+ name.removeprefix("ogc_internal_")
+ for name in _literal_view_names(INTERNAL_MIGRATION)
+ } | internal_thing_ids
+
+ missing = public_relation_ids - internal_relation_ids
+ assert not missing, (
+ "public ogc_* relations with no ogc_internal_ mirror in "
+ f"{INTERNAL_MIGRATION.name}: {sorted(missing)}"
+ )
+ assert len(public_relation_ids) == 24, (
+ "expected 24 total relations (11 thing-type + 11 from f4a5b6c7d8e9 + "
+ f"2 EDR from z9a0b1c2d3e4), got {len(public_relation_ids)}: "
+ f"{sorted(public_relation_ids)}"
+ )
From 02594c37630de8f682a42e1f448a17d41040c48c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 3 Aug 2026 21:50:48 +0000
Subject: [PATCH 032/151] build(deps): bump cryptography from 48.0.1 to 50.0.0
Bumps [cryptography](https://github.com/pyca/cryptography) from 48.0.1 to 50.0.0.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/48.0.1...50.0.0)
---
updated-dependencies:
- dependency-name: cryptography
dependency-version: 50.0.0
dependency-type: direct:production
...
Signed-off-by: dependabot[bot]
---
pyproject.toml | 2 +-
requirements.txt | 91 +++++++++++++++++++++++++-----------------------
uv.lock | 89 +++++++++++++++++++++++-----------------------
3 files changed, 91 insertions(+), 91 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index dd4eb7fe0..9bcad145b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,7 +26,7 @@ dependencies = [
"charset-normalizer==3.4.9",
"click==8.4.2",
"cloud-sql-python-connector==1.21.0",
- "cryptography==48.0.1",
+ "cryptography==50.0.0",
"dnspython==2.8.0",
"dotenv==0.9.9",
"email-validator==2.3.0",
diff --git a/requirements.txt b/requirements.txt
index 68b1c0770..2f1437493 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -526,50 +526,53 @@ colorama==0.4.6 ; sys_platform == 'win32' \
# via
# click
# typer
-cryptography==48.0.1 \
- --hash=sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02 \
- --hash=sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471 \
- --hash=sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f \
- --hash=sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa \
- --hash=sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a \
- --hash=sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1 \
- --hash=sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225 \
- --hash=sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6 \
- --hash=sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24 \
- --hash=sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1 \
- --hash=sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f \
- --hash=sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72 \
- --hash=sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6 \
- --hash=sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8 \
- --hash=sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577 \
- --hash=sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67 \
- --hash=sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429 \
- --hash=sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1 \
- --hash=sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265 \
- --hash=sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a \
- --hash=sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475 \
- --hash=sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d \
- --hash=sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3 \
- --hash=sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1 \
- --hash=sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac \
- --hash=sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6 \
- --hash=sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2 \
- --hash=sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08 \
- --hash=sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c \
- --hash=sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b \
- --hash=sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401 \
- --hash=sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158 \
- --hash=sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8 \
- --hash=sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9 \
- --hash=sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411 \
- --hash=sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4 \
- --hash=sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991 \
- --hash=sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17 \
- --hash=sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242 \
- --hash=sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691 \
- --hash=sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41 \
- --hash=sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345 \
- --hash=sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46
+cryptography==50.0.0 \
+ --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \
+ --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \
+ --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \
+ --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \
+ --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \
+ --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \
+ --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \
+ --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \
+ --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \
+ --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \
+ --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \
+ --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \
+ --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \
+ --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \
+ --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \
+ --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \
+ --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \
+ --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \
+ --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \
+ --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \
+ --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \
+ --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \
+ --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \
+ --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \
+ --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \
+ --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \
+ --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \
+ --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \
+ --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \
+ --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \
+ --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \
+ --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \
+ --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \
+ --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \
+ --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \
+ --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \
+ --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \
+ --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \
+ --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \
+ --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \
+ --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \
+ --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \
+ --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \
+ --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \
+ --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \
+ --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645
# via
# authlib
# cloud-sql-python-connector
diff --git a/uv.lock b/uv.lock
index 195ad31ef..6cecc7ccb 100644
--- a/uv.lock
+++ b/uv.lock
@@ -648,55 +648,52 @@ wheels = [
[[package]]
name = "cryptography"
-version = "48.0.1"
+version = "50.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
- { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
- { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
- { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
- { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
- { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
- { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
- { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
- { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
- { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
- { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
- { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
- { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
- { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
- { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" },
- { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
- { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
- { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
- { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" },
- { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
- { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
- { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
- { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" },
- { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
- { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
- { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
- { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" },
- { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" },
- { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
- { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
- { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
- { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
- { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
- { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
- { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
- { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
- { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
- { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
- { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
- { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
- { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
- { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
+ { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
+ { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
+ { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
+ { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
+ { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
+ { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
+ { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
+ { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
+ { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
+ { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
+ { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
+ { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
+ { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
+ { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
+ { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
+ { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
+ { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
+ { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
]
[[package]]
@@ -1726,7 +1723,7 @@ requires-dist = [
{ name = "charset-normalizer", specifier = "==3.4.9" },
{ name = "click", specifier = "==8.4.2" },
{ name = "cloud-sql-python-connector", specifier = "==1.21.0" },
- { name = "cryptography", specifier = "==48.0.1" },
+ { name = "cryptography", specifier = "==50.0.0" },
{ name = "dnspython", specifier = "==2.8.0" },
{ name = "dotenv", specifier = "==0.9.9" },
{ name = "email-validator", specifier = "==2.3.0" },
From 013fd7554702f9e4c13ebdf006cd726f4b957c8e Mon Sep 17 00:00:00 2001
From: jakeross
Date: Thu, 6 Aug 2026 10:51:00 -0700
Subject: [PATCH 033/151] feat(geothermal): normalize OGC view temperatures to
Celsius
The per-well geothermal views from d1e2f3a4b5c6 published legacy
temperatures unconverted and labelled them with max("TempUnit"), which
picks a unit lexically. A well with both C and F readings was labelled
'F' while values stayed mixed, and min/max aggregated across units.
Add public.nmw_temp_unit_code() and public.nmw_temp_to_c(), then rebuild
ogc_geothermal_wells_bht and ogc_geothermal_wells_temperature_profile
with min/max_bht_c, min/max_temp_c, a constant 'C' temp_unit, a
temp_unit_source listing the distinct source units, a temp_unit_mixed
flag, and an unconvertible_count. Raw columns and the series 'temp' key
are unchanged for compatibility; series objects gain temp_c and
temp_unit_source.
Function references are schema-qualified because materialized view
population runs with a restricted search_path, which breaks inlining of
an unqualified nested call.
Co-Authored-By: Claude Opus 5
---
..._normalize_geothermal_temperature_units.py | 338 ++++++++++++++++++
1 file changed, 338 insertions(+)
create mode 100644 alembic/versions/f3a1c2b4d5e6_normalize_geothermal_temperature_units.py
diff --git a/alembic/versions/f3a1c2b4d5e6_normalize_geothermal_temperature_units.py b/alembic/versions/f3a1c2b4d5e6_normalize_geothermal_temperature_units.py
new file mode 100644
index 000000000..2f6ea05b9
--- /dev/null
+++ b/alembic/versions/f3a1c2b4d5e6_normalize_geothermal_temperature_units.py
@@ -0,0 +1,338 @@
+"""Normalize geothermal OGC view temperatures to Celsius
+
+Revision ID: f3a1c2b4d5e6
+Revises: 2d3c3a268652
+Create Date: 2026-08-06
+
+The geothermal per-well views created in d1e2f3a4b5c6 passed legacy
+temperatures through unconverted and labelled them with ``max("TempUnit")``.
+That is wrong two ways:
+
+ 1. ``max()`` over a mixed-unit well picks a unit lexically ('F' > 'C'),
+ so a well holding both C and F readings was labelled 'F' while the
+ values stayed mixed.
+ 2. ``min("Temp")`` / ``max("Temp")`` aggregate across those mixed units,
+ so 100 F sorts above 40 C and the extremes are meaningless.
+
+This revision adds two helper functions and rebuilds the two temperature
+views so every temperature is also published in Celsius:
+
+ nmw_temp_unit_code(text) -> text
+ Canonicalizes a legacy unit string to 'C', 'F', 'K', or NULL when
+ unrecognized. NMW_GtTempDepths."TempUnit" is String(1) while
+ NMW_GtBhtData."TempUnit" is String(5), so both single-letter codes
+ and spelled-out forms are accepted.
+
+ nmw_temp_to_c(double precision, text) -> double precision
+ Converts a value to Celsius using that code. Returns NULL when the
+ unit is unrecognized rather than assuming a default, so unconvertible
+ readings are visible instead of silently wrong.
+
+Changes to ogc_geothermal_wells_bht and
+ogc_geothermal_wells_temperature_profile:
+
+ * new ``*_c`` columns (min_bht_c/max_bht_c, min_temp_c/max_temp_c)
+ aggregated over normalized values -- these are the ones to chart.
+ * ``temp_unit`` is now the constant 'C', describing the ``*_c`` columns.
+ * new ``temp_unit_source`` lists the distinct source units actually
+ present for the well ('C', 'F', 'C,F', 'UNKNOWN', ...), and
+ ``temp_unit_mixed`` flags wells that mix units.
+ * new ``unconvertible_count`` counts readings whose unit was not
+ recognized (present in the raw columns, NULL in the ``*_c`` columns).
+ * pre-existing raw columns (min_bht/max_bht, min_temp/max_temp, and the
+ profile ``series`` 'temp' key) are kept unchanged for compatibility.
+ They remain mixed-unit; consumers should move to the ``*_c`` columns.
+ * profile ``series`` objects gain 'temp_c' and 'temp_unit_source'.
+
+Heat-flow units (HtFlowUnit, GradUnit, TCondUnit, Q_unit, Kpr_unit, Ka_unit)
+and depth units are NOT normalized here -- the summary and interval heat-flow
+views are untouched.
+
+Rebuilding ogc_geothermal_wells_temperature_profile drops and recreates the
+materialized view, which repopulates it WITH DATA. Expect the usual matview
+build cost against the ~370k-row NMW_GtTempDepths source.
+"""
+
+from alembic import op
+from sqlalchemy import text
+
+revision = "f3a1c2b4d5e6"
+down_revision = "2d3c3a268652"
+branch_labels = None
+depends_on = None
+
+_BHT_VIEW = "ogc_geothermal_wells_bht"
+_PROFILE_VIEW = "ogc_geothermal_wells_temperature_profile"
+
+_LOC_CTE = """
+ WITH loc AS (
+ SELECT DISTINCT ON ("WellDataID")
+ "WellDataID", "Lat_dd83", "Long_dd83"
+ FROM "NMW_WellLocations"
+ WHERE "Lat_dd83" IS NOT NULL
+ AND "Long_dd83" IS NOT NULL
+ ORDER BY "WellDataID", "OBJECTID"
+ )
+"""
+
+
+def upgrade() -> None:
+ op.execute(
+ text(
+ """
+ CREATE OR REPLACE FUNCTION public.nmw_temp_unit_code(unit text)
+ RETURNS text
+ LANGUAGE sql
+ IMMUTABLE
+ AS $$
+ SELECT CASE upper(regexp_replace(coalesce(unit, ''), '[^A-Za-z]', '', 'g'))
+ WHEN 'C' THEN 'C'
+ WHEN 'DEGC' THEN 'C'
+ WHEN 'DEGREESC' THEN 'C'
+ WHEN 'CELSIUS' THEN 'C'
+ WHEN 'CENTIGRADE' THEN 'C'
+ WHEN 'F' THEN 'F'
+ WHEN 'DEGF' THEN 'F'
+ WHEN 'DEGREESF' THEN 'F'
+ WHEN 'FAHRENHEIT' THEN 'F'
+ WHEN 'K' THEN 'K'
+ WHEN 'DEGK' THEN 'K'
+ WHEN 'KELVIN' THEN 'K'
+ ELSE NULL
+ END
+ $$
+ """
+ )
+ )
+
+ op.execute(
+ text(
+ """
+ CREATE OR REPLACE FUNCTION public.nmw_temp_to_c(val double precision, unit text)
+ RETURNS double precision
+ LANGUAGE sql
+ IMMUTABLE
+ AS $$
+ SELECT CASE public.nmw_temp_unit_code(unit)
+ WHEN 'C' THEN val
+ WHEN 'F' THEN (val - 32.0) * 5.0 / 9.0
+ WHEN 'K' THEN val - 273.15
+ ELSE NULL
+ END
+ $$
+ """
+ )
+ )
+
+ # ogc_geothermal_wells_bht
+ op.execute(text(f'DROP VIEW IF EXISTS "{_BHT_VIEW}"'))
+ op.execute(
+ text(
+ f"""
+ CREATE VIEW "{_BHT_VIEW}" AS
+ {_LOC_CTE}
+ SELECT
+ row_number() OVER () AS id,
+ r."WellDataID"::text AS well_data_id,
+ hdr."CurWellNam" AS well_name,
+ hdr."API" AS api,
+ hdr."TotalDepth" AS total_depth,
+ count(d.*) AS bht_count,
+ max(d."BHT") AS max_bht,
+ min(d."BHT") AS min_bht,
+ max(public.nmw_temp_to_c(d."BHT", d."TempUnit")) AS max_bht_c,
+ min(public.nmw_temp_to_c(d."BHT", d."TempUnit")) AS min_bht_c,
+ max(d."Depth") AS max_bht_depth,
+ 'C'::text AS temp_unit,
+ string_agg(
+ DISTINCT coalesce(public.nmw_temp_unit_code(d."TempUnit"), 'UNKNOWN'),
+ ','
+ ORDER BY coalesce(public.nmw_temp_unit_code(d."TempUnit"), 'UNKNOWN')
+ ) AS temp_unit_source,
+ count(DISTINCT coalesce(public.nmw_temp_unit_code(d."TempUnit"), 'UNKNOWN')) > 1
+ AS temp_unit_mixed,
+ count(*) FILTER (
+ WHERE d."BHT" IS NOT NULL
+ AND public.nmw_temp_to_c(d."BHT", d."TempUnit") IS NULL
+ ) AS unconvertible_count,
+ ST_SetSRID(
+ ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326
+ ) AS geom
+ FROM "NMW_GtBhtData" AS d
+ JOIN "NMW_GtBhtHeaders" AS h ON h."BHTGUID" = d."BHTGUID"
+ JOIN "NMW_WellSamples" AS s ON s."SamplSetID" = h."SamplSetID"
+ JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = s."RecrdsetID"
+ JOIN loc ON loc."WellDataID" = r."WellDataID"
+ LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID"
+ GROUP BY
+ r."WellDataID",
+ loc."Lat_dd83",
+ loc."Long_dd83",
+ hdr."CurWellNam",
+ hdr."API",
+ hdr."TotalDepth"
+ """
+ )
+ )
+
+ # ogc_geothermal_wells_temperature_profile (materialized)
+ op.execute(text(f'DROP MATERIALIZED VIEW IF EXISTS "{_PROFILE_VIEW}"'))
+ op.execute(
+ text(
+ f"""
+ CREATE MATERIALIZED VIEW "{_PROFILE_VIEW}" AS
+ {_LOC_CTE}
+ SELECT
+ row_number() OVER () AS id,
+ r."WellDataID"::text AS well_data_id,
+ hdr."CurWellNam" AS well_name,
+ hdr."API" AS api,
+ count(td.*) AS reading_count,
+ min(td."Depth") AS min_depth,
+ max(td."Depth") AS max_depth,
+ min(td."Temp") AS min_temp,
+ max(td."Temp") AS max_temp,
+ min(public.nmw_temp_to_c(td."Temp", td."TempUnit")) AS min_temp_c,
+ max(public.nmw_temp_to_c(td."Temp", td."TempUnit")) AS max_temp_c,
+ 'C'::text AS temp_unit,
+ string_agg(
+ DISTINCT coalesce(public.nmw_temp_unit_code(td."TempUnit"), 'UNKNOWN'),
+ ','
+ ORDER BY coalesce(public.nmw_temp_unit_code(td."TempUnit"), 'UNKNOWN')
+ ) AS temp_unit_source,
+ count(DISTINCT coalesce(public.nmw_temp_unit_code(td."TempUnit"), 'UNKNOWN')) > 1
+ AS temp_unit_mixed,
+ count(*) FILTER (
+ WHERE public.nmw_temp_to_c(td."Temp", td."TempUnit") IS NULL
+ ) AS unconvertible_count,
+ json_agg(
+ json_build_object(
+ 'depth', td."Depth",
+ 'temp', td."Temp",
+ 'temp_c', public.nmw_temp_to_c(td."Temp", td."TempUnit"),
+ 'temp_unit_source',
+ coalesce(public.nmw_temp_unit_code(td."TempUnit"), 'UNKNOWN')
+ )
+ ORDER BY td."Depth"
+ ) AS series,
+ ST_SetSRID(
+ ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326
+ ) AS geom
+ FROM "NMW_GtTempDepths" AS td
+ JOIN "NMW_WellSamples" AS s ON s."SamplSetID" = td."SamplSetID"
+ JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = s."RecrdsetID"
+ JOIN loc ON loc."WellDataID" = r."WellDataID"
+ LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID"
+ WHERE td."Depth" IS NOT NULL
+ AND td."Temp" IS NOT NULL
+ GROUP BY
+ r."WellDataID",
+ loc."Lat_dd83",
+ loc."Long_dd83",
+ hdr."CurWellNam",
+ hdr."API"
+ """
+ )
+ )
+ op.execute(
+ text(f'CREATE UNIQUE INDEX ux_{_PROFILE_VIEW}_id ON "{_PROFILE_VIEW}" (id)')
+ )
+ op.execute(
+ text(
+ f'CREATE INDEX ix_{_PROFILE_VIEW}_geom ON "{_PROFILE_VIEW}" USING GIST (geom)'
+ )
+ )
+
+
+def downgrade() -> None:
+ # Restore the d1e2f3a4b5c6 definitions verbatim, then drop the helpers.
+ op.execute(text(f'DROP VIEW IF EXISTS "{_BHT_VIEW}"'))
+ op.execute(
+ text(
+ f"""
+ CREATE VIEW "{_BHT_VIEW}" AS
+ {_LOC_CTE}
+ SELECT
+ row_number() OVER () AS id,
+ r."WellDataID"::text AS well_data_id,
+ hdr."CurWellNam" AS well_name,
+ hdr."API" AS api,
+ hdr."TotalDepth" AS total_depth,
+ count(d.*) AS bht_count,
+ max(d."BHT") AS max_bht,
+ min(d."BHT") AS min_bht,
+ max(d."Depth") AS max_bht_depth,
+ max(d."TempUnit") AS temp_unit,
+ ST_SetSRID(
+ ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326
+ ) AS geom
+ FROM "NMW_GtBhtData" AS d
+ JOIN "NMW_GtBhtHeaders" AS h ON h."BHTGUID" = d."BHTGUID"
+ JOIN "NMW_WellSamples" AS s ON s."SamplSetID" = h."SamplSetID"
+ JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = s."RecrdsetID"
+ JOIN loc ON loc."WellDataID" = r."WellDataID"
+ LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID"
+ GROUP BY
+ r."WellDataID",
+ loc."Lat_dd83",
+ loc."Long_dd83",
+ hdr."CurWellNam",
+ hdr."API",
+ hdr."TotalDepth"
+ """
+ )
+ )
+
+ op.execute(text(f'DROP MATERIALIZED VIEW IF EXISTS "{_PROFILE_VIEW}"'))
+ op.execute(
+ text(
+ f"""
+ CREATE MATERIALIZED VIEW "{_PROFILE_VIEW}" AS
+ {_LOC_CTE}
+ SELECT
+ row_number() OVER () AS id,
+ r."WellDataID"::text AS well_data_id,
+ hdr."CurWellNam" AS well_name,
+ hdr."API" AS api,
+ count(td.*) AS reading_count,
+ min(td."Depth") AS min_depth,
+ max(td."Depth") AS max_depth,
+ min(td."Temp") AS min_temp,
+ max(td."Temp") AS max_temp,
+ max(td."TempUnit") AS temp_unit,
+ json_agg(
+ json_build_object('depth', td."Depth", 'temp', td."Temp")
+ ORDER BY td."Depth"
+ ) AS series,
+ ST_SetSRID(
+ ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326
+ ) AS geom
+ FROM "NMW_GtTempDepths" AS td
+ JOIN "NMW_WellSamples" AS s ON s."SamplSetID" = td."SamplSetID"
+ JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = s."RecrdsetID"
+ JOIN loc ON loc."WellDataID" = r."WellDataID"
+ LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID"
+ WHERE td."Depth" IS NOT NULL
+ AND td."Temp" IS NOT NULL
+ GROUP BY
+ r."WellDataID",
+ loc."Lat_dd83",
+ loc."Long_dd83",
+ hdr."CurWellNam",
+ hdr."API"
+ """
+ )
+ )
+ op.execute(
+ text(f'CREATE UNIQUE INDEX ux_{_PROFILE_VIEW}_id ON "{_PROFILE_VIEW}" (id)')
+ )
+ op.execute(
+ text(
+ f'CREATE INDEX ix_{_PROFILE_VIEW}_geom ON "{_PROFILE_VIEW}" USING GIST (geom)'
+ )
+ )
+
+ op.execute(
+ text("DROP FUNCTION IF EXISTS public.nmw_temp_to_c(double precision, text)")
+ )
+ op.execute(text("DROP FUNCTION IF EXISTS public.nmw_temp_unit_code(text)"))
From abcf8ba9c0488e3a6a157f570dd7e14c836352b8 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Fri, 7 Aug 2026 01:36:53 -0700
Subject: [PATCH 034/151] test(geothermal): cover temperature unit
normalization
Adds coverage for the f3a1c2b4d5e6 helpers and views: unit-code
canonicalization across single-letter and spelled-out legacy forms,
F/C/K conversion plus the NULL-on-unknown-unit contract, presence of the
Celsius columns on both temperature views, and that temp_unit publishes
the constant 'C'.
Matview columns are read from pg_attribute because materialized views do
not appear in information_schema.columns.
Co-Authored-By: Claude Opus 5
---
tests/test_nmw_mirror.py | 117 +++++++++++++++++++++++++++++++++++++++
1 file changed, 117 insertions(+)
diff --git a/tests/test_nmw_mirror.py b/tests/test_nmw_mirror.py
index a6a785867..4de183fe4 100644
--- a/tests/test_nmw_mirror.py
+++ b/tests/test_nmw_mirror.py
@@ -204,6 +204,123 @@ def test_geothermal_collections_back_existing_relations():
), f"backing relation {expected_table} for {coll} does not exist in DB"
+# ------------------------------------------------------------ temperature units
+@pytest.mark.parametrize(
+ "unit,expected",
+ [
+ ("C", "C"),
+ ("c", "C"),
+ (" degC ", "C"),
+ ("Celsius", "C"),
+ ("F", "F"),
+ ("degF", "F"),
+ ("FAHRENHEIT", "F"),
+ ("K", "K"),
+ ("Kelvin", "K"),
+ ("bogus", None),
+ ("", None),
+ (None, None),
+ ],
+)
+def test_temp_unit_code_canonicalizes_legacy_units(unit, expected):
+ """Legacy TempUnit strings collapse to C/F/K, or NULL when unrecognized.
+
+ NMW_GtTempDepths."TempUnit" is String(1) and NMW_GtBhtData."TempUnit" is
+ String(5), so both single-letter and spelled-out forms reach the views.
+ """
+ with session_ctx() as session:
+ got = session.execute(
+ text("SELECT public.nmw_temp_unit_code(:u)"), {"u": unit}
+ ).scalar()
+ assert got == expected
+
+
+@pytest.mark.parametrize(
+ "value,unit,expected",
+ [
+ (212.0, "F", 100.0),
+ (32.0, "F", 0.0),
+ (40.0, "C", 40.0),
+ (273.15, "K", 0.0),
+ (50.0, "bogus", None), # unknown unit -> NULL, never an assumed default
+ (50.0, None, None),
+ (None, "F", None),
+ ],
+)
+def test_temp_to_c_converts_or_nulls(value, unit, expected):
+ with session_ctx() as session:
+ got = session.execute(
+ text("SELECT public.nmw_temp_to_c(:v, :u)"), {"v": value, "u": unit}
+ ).scalar()
+ if expected is None:
+ assert got is None
+ else:
+ assert got == pytest.approx(expected)
+
+
+@pytest.mark.parametrize(
+ "relation,columns",
+ [
+ (
+ "ogc_geothermal_wells_bht",
+ {
+ "min_bht_c",
+ "max_bht_c",
+ "temp_unit",
+ "temp_unit_source",
+ "temp_unit_mixed",
+ "unconvertible_count",
+ },
+ ),
+ (
+ "ogc_geothermal_wells_temperature_profile",
+ {
+ "min_temp_c",
+ "max_temp_c",
+ "temp_unit",
+ "temp_unit_source",
+ "temp_unit_mixed",
+ "unconvertible_count",
+ },
+ ),
+ ],
+)
+def test_temperature_views_publish_celsius_columns(relation, columns):
+ """Both temperature views carry normalized Celsius columns alongside the
+ raw legacy ones (pg_attribute, since matviews are not in
+ information_schema.columns)."""
+ with session_ctx() as session:
+ present = {
+ r[0]
+ for r in session.execute(
+ text(
+ "SELECT attname FROM pg_attribute "
+ "WHERE attrelid = cast(:rel AS regclass) AND attnum > 0 "
+ "AND NOT attisdropped"
+ ),
+ {"rel": relation},
+ ).all()
+ }
+ assert columns <= present, f"{relation} missing {sorted(columns - present)}"
+
+
+def test_temperature_views_label_celsius_not_source_unit():
+ """temp_unit describes the *_c columns, so it is the constant 'C' rather
+ than a lexical max() over mixed source units."""
+ with session_ctx() as session:
+ for relation in (
+ "ogc_geothermal_wells_bht",
+ "ogc_geothermal_wells_temperature_profile",
+ ):
+ units = {
+ r[0]
+ for r in session.execute(
+ text(f'SELECT DISTINCT temp_unit FROM "{relation}"') # noqa: S608
+ ).all()
+ }
+ assert units <= {"C"}, f"{relation} published temp_unit {units}"
+
+
# ----------------------------------------------------------------- dump parser
@pytest.mark.parametrize(
"raw,expected",
From 54c4ba76732f7aeb17fedf00c6f22a09c7919a62 Mon Sep 17 00:00:00 2001
From: jirhiker <2035568+jirhiker@users.noreply.github.com>
Date: Fri, 7 Aug 2026 08:37:22 +0000
Subject: [PATCH 035/151] Formatting changes
---
..._normalize_geothermal_temperature_units.py | 48 +++++--------------
1 file changed, 12 insertions(+), 36 deletions(-)
diff --git a/alembic/versions/f3a1c2b4d5e6_normalize_geothermal_temperature_units.py b/alembic/versions/f3a1c2b4d5e6_normalize_geothermal_temperature_units.py
index 2f6ea05b9..4eb0ee6ed 100644
--- a/alembic/versions/f3a1c2b4d5e6_normalize_geothermal_temperature_units.py
+++ b/alembic/versions/f3a1c2b4d5e6_normalize_geothermal_temperature_units.py
@@ -77,9 +77,7 @@
def upgrade() -> None:
- op.execute(
- text(
- """
+ op.execute(text("""
CREATE OR REPLACE FUNCTION public.nmw_temp_unit_code(unit text)
RETURNS text
LANGUAGE sql
@@ -101,13 +99,9 @@ def upgrade() -> None:
ELSE NULL
END
$$
- """
- )
- )
+ """))
- op.execute(
- text(
- """
+ op.execute(text("""
CREATE OR REPLACE FUNCTION public.nmw_temp_to_c(val double precision, unit text)
RETURNS double precision
LANGUAGE sql
@@ -120,15 +114,11 @@ def upgrade() -> None:
ELSE NULL
END
$$
- """
- )
- )
+ """))
# ogc_geothermal_wells_bht
op.execute(text(f'DROP VIEW IF EXISTS "{_BHT_VIEW}"'))
- op.execute(
- text(
- f"""
+ op.execute(text(f"""
CREATE VIEW "{_BHT_VIEW}" AS
{_LOC_CTE}
SELECT
@@ -171,15 +161,11 @@ def upgrade() -> None:
hdr."CurWellNam",
hdr."API",
hdr."TotalDepth"
- """
- )
- )
+ """))
# ogc_geothermal_wells_temperature_profile (materialized)
op.execute(text(f'DROP MATERIALIZED VIEW IF EXISTS "{_PROFILE_VIEW}"'))
- op.execute(
- text(
- f"""
+ op.execute(text(f"""
CREATE MATERIALIZED VIEW "{_PROFILE_VIEW}" AS
{_LOC_CTE}
SELECT
@@ -231,9 +217,7 @@ def upgrade() -> None:
loc."Long_dd83",
hdr."CurWellNam",
hdr."API"
- """
- )
- )
+ """))
op.execute(
text(f'CREATE UNIQUE INDEX ux_{_PROFILE_VIEW}_id ON "{_PROFILE_VIEW}" (id)')
)
@@ -247,9 +231,7 @@ def upgrade() -> None:
def downgrade() -> None:
# Restore the d1e2f3a4b5c6 definitions verbatim, then drop the helpers.
op.execute(text(f'DROP VIEW IF EXISTS "{_BHT_VIEW}"'))
- op.execute(
- text(
- f"""
+ op.execute(text(f"""
CREATE VIEW "{_BHT_VIEW}" AS
{_LOC_CTE}
SELECT
@@ -279,14 +261,10 @@ def downgrade() -> None:
hdr."CurWellNam",
hdr."API",
hdr."TotalDepth"
- """
- )
- )
+ """))
op.execute(text(f'DROP MATERIALIZED VIEW IF EXISTS "{_PROFILE_VIEW}"'))
- op.execute(
- text(
- f"""
+ op.execute(text(f"""
CREATE MATERIALIZED VIEW "{_PROFILE_VIEW}" AS
{_LOC_CTE}
SELECT
@@ -320,9 +298,7 @@ def downgrade() -> None:
loc."Long_dd83",
hdr."CurWellNam",
hdr."API"
- """
- )
- )
+ """))
op.execute(
text(f'CREATE UNIQUE INDEX ux_{_PROFILE_VIEW}_id ON "{_PROFILE_VIEW}" (id)')
)
From 860a3f45ed7ab0ca352b69d006324c8f9fb7a289 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Thu, 6 Aug 2026 12:47:52 -0600
Subject: [PATCH 036/151] fix(ogc): isolate public and internal pygeoapi module
globals
Both mounts were built by reloading pygeoapi.starlette_app, which
rebinds that module's globals in place. Route handlers read `api_`
out of those globals when a request arrives, so loading the internal
mount second retargeted the handlers already registered on the public
app: /ogcapi served the unfiltered ogc_internal_* views, defeating the
A1 release_status = 'public' filter.
Each mount now loads its own copy of the module under a distinct
sys.modules key, so the two sets of globals cannot alias. The config
env vars are restored after each load as well, since they are read
only during import and the last mount's values would otherwise decide
the config for any later importer.
The two tests this replaces asserted the reload behaviour itself,
which is why the defect went unnoticed; the new ones assert that no
public collection resolves to an ogc_internal_ relation.
---
core/pygeoapi.py | 56 ++++++++++-----
tests/test_pygeoapi_mount.py | 131 +++++++++++++++++++++++++----------
2 files changed, 136 insertions(+), 51 deletions(-)
diff --git a/core/pygeoapi.py b/core/pygeoapi.py
index 5d6503011..e5bb3c34a 100644
--- a/core/pygeoapi.py
+++ b/core/pygeoapi.py
@@ -1,4 +1,4 @@
-import importlib
+import importlib.util
import os
import re
import sys
@@ -9,6 +9,9 @@
import yaml
from fastapi import FastAPI
+# Consumed by pygeoapi at import time only; see _load_pygeoapi_app.
+_PYGEOAPI_ENV_KEYS = ("PYGEOAPI_CONFIG", "PYGEOAPI_OPENAPI")
+
THING_COLLECTIONS = [
{
"id": "water_wells",
@@ -404,8 +407,10 @@ def _assert_server_settings_match(
public_config_path: Path, internal_config_path: Path
) -> None:
# pygeoapi.api.API.__init__ mutates process-wide, module-level globals
- # (CHARSET, FORMAT_TYPES) that persist across the importlib.reload this
- # scheme relies on -- whichever mount is constructed last wins for both.
+ # (CHARSET, FORMAT_TYPES). Loading each mount from its own copy of
+ # pygeoapi.starlette_app does not help here, since both copies still
+ # share the one pygeoapi.api module -- whichever mount is constructed
+ # last wins for both.
# Inert as long as both configs agree on these settings; fail loudly at
# startup rather than let a future divergence silently corrupt responses
# on whichever mount lost the race.
@@ -437,12 +442,37 @@ def _generate_openapi(config_path: Path, openapi_path: Path) -> None:
openapi_path.write_text(openapi, encoding="utf-8")
-def _load_pygeoapi_app():
+def _load_pygeoapi_app(instance: str, config_path: Path, openapi_path: Path):
+ # pygeoapi.starlette_app resolves PYGEOAPI_CONFIG at import time into a
+ # module-level `api_`, and every route handler looks that name up in the
+ # module's globals at request time. importlib.reload() rebinds those
+ # globals *in place*, so reloading for the second mount retargets the
+ # handlers of the app already built for the first one -- both mounts end
+ # up serving whichever config was loaded last. Give each mount its own
+ # module object so the two sets of globals can never alias.
module_name = "pygeoapi.starlette_app"
- if module_name in sys.modules:
- module = importlib.reload(sys.modules[module_name])
- else:
- module = importlib.import_module(module_name)
+ spec = find_spec(module_name)
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"Unable to locate {module_name} for the {instance} mount.")
+
+ module = importlib.util.module_from_spec(spec)
+ # Registered before exec_module so the module can survive importing itself.
+ sys.modules[f"{module_name}__ocotillo_{instance}"] = module
+
+ previous = {key: os.environ.get(key) for key in _PYGEOAPI_ENV_KEYS}
+ os.environ["PYGEOAPI_CONFIG"] = str(config_path)
+ os.environ["PYGEOAPI_OPENAPI"] = str(openapi_path)
+ try:
+ spec.loader.exec_module(module)
+ finally:
+ # These are read only during import, so leaving the last mount's paths
+ # behind would silently decide the config for any later importer.
+ for key, value in previous.items():
+ if value is None:
+ os.environ.pop(key, None)
+ else:
+ os.environ[key] = value
+
return module.APP
@@ -461,10 +491,7 @@ def mount_pygeoapi(app: FastAPI) -> None:
_write_config(config_path, server_url=_server_url(), include_edr=True)
_generate_openapi(config_path, openapi_path)
- os.environ["PYGEOAPI_CONFIG"] = str(config_path)
- os.environ["PYGEOAPI_OPENAPI"] = str(openapi_path)
-
- pygeoapi_app = _load_pygeoapi_app()
+ pygeoapi_app = _load_pygeoapi_app("public", config_path, openapi_path)
mount_path = _mount_path()
app.mount(mount_path, pygeoapi_app)
@@ -507,10 +534,7 @@ def mount_pygeoapi_internal(app: FastAPI) -> None:
_generate_openapi(config_path, openapi_path)
_assert_server_settings_match(_pygeoapi_dir() / "pygeoapi-config.yml", config_path)
- os.environ["PYGEOAPI_CONFIG"] = str(config_path)
- os.environ["PYGEOAPI_OPENAPI"] = str(openapi_path)
-
- pygeoapi_app = _load_pygeoapi_app()
+ pygeoapi_app = _load_pygeoapi_app("internal", config_path, openapi_path)
from core.internal_ogc_auth import InternalOGCAuthMiddleware
diff --git a/tests/test_pygeoapi_mount.py b/tests/test_pygeoapi_mount.py
index c789dc306..e3457a75c 100644
--- a/tests/test_pygeoapi_mount.py
+++ b/tests/test_pygeoapi_mount.py
@@ -1,50 +1,111 @@
-import types
+"""Isolation guarantees for the public (/ogcapi) and internal (/ogcapi-internal) mounts.
-from core import pygeoapi
+Both mounts are built from the same pygeoapi.starlette_app source, which
+resolves PYGEOAPI_CONFIG at import time into a module-level ``api_`` that
+every route handler reads out of module globals at request time. Loading the
+second mount by reloading that module rebinds those globals in place, which
+silently retargets the already-built first mount -- the public mount then
+serves the unfiltered ogc_internal_* views, defeating the A1
+``release_status = 'public'`` filter. These tests pin the isolation that
+prevents that.
+
+Importing this module builds the app (via the tests package), so both
+runtime config files already exist on disk by the time a test runs.
+"""
+import os
+import sys
+
+from core import pygeoapi
-def test_load_pygeoapi_app_imports_when_module_not_loaded(monkeypatch):
- fake_module = types.SimpleNamespace(APP=object())
- import_calls = []
+PUBLIC_MODULE = "pygeoapi.starlette_app__ocotillo_public"
+INTERNAL_MODULE = "pygeoapi.starlette_app__ocotillo_internal"
- def fake_import_module(name):
- import_calls.append(name)
- return fake_module
- monkeypatch.delitem(
- pygeoapi.sys.modules,
- "pygeoapi.starlette_app",
- raising=False,
+def _mount_args():
+ public_dir = pygeoapi._pygeoapi_dir()
+ internal_dir = pygeoapi._pygeoapi_dir(
+ "PYGEOAPI_INTERNAL_RUNTIME_DIR", "/tmp/pygeoapi-internal"
)
- monkeypatch.setattr(
- pygeoapi.importlib,
- "import_module",
- fake_import_module,
+ return (
+ (
+ "public",
+ public_dir / "pygeoapi-config.yml",
+ public_dir / "pygeoapi-openapi.yml",
+ ),
+ (
+ "internal",
+ internal_dir / "pygeoapi-config.yml",
+ internal_dir / "pygeoapi-openapi.yml",
+ ),
)
- app = pygeoapi._load_pygeoapi_app()
- assert app is fake_module.APP
- assert import_calls == ["pygeoapi.starlette_app"]
+def _load_both():
+ # Internal last, matching create_api_app's order -- the order that used
+ # to leave the public mount pointing at ogc_internal_* relations.
+ public, internal = _mount_args()
+ pygeoapi._load_pygeoapi_app(*public)
+ pygeoapi._load_pygeoapi_app(*internal)
+ return sys.modules[PUBLIC_MODULE], sys.modules[INTERNAL_MODULE]
-def test_load_pygeoapi_app_reloads_when_module_already_loaded(monkeypatch):
- existing_module = types.SimpleNamespace(APP=object())
- reloaded_module = types.SimpleNamespace(APP=object())
- reload_calls = []
+def _provider_tables(api):
+ return {
+ name: resource["providers"][0].get("table")
+ for name, resource in api.config["resources"].items()
+ if resource.get("providers")
+ }
- def fake_reload(module):
- reload_calls.append(module)
- return reloaded_module
- monkeypatch.setitem(
- pygeoapi.sys.modules,
- "pygeoapi.starlette_app",
- existing_module,
- )
- monkeypatch.setattr(pygeoapi.importlib, "reload", fake_reload)
+def test_each_mount_gets_independent_module_globals():
+ public_module, internal_module = _load_both()
+
+ assert public_module is not internal_module
+ # The aliasing that caused the leak: one shared dict, so one shared api_.
+ assert public_module.__dict__ is not internal_module.__dict__
+ assert public_module.api_ is not internal_module.api_
+
+
+def test_public_mount_does_not_resolve_to_internal_relations():
+ public_module, internal_module = _load_both()
+
+ public_tables = _provider_tables(public_module.api_)
+ assert public_tables, "public config exposed no provider-backed collections"
+ leaked = {
+ name: table
+ for name, table in public_tables.items()
+ if table and table.startswith("ogc_internal_")
+ }
+ assert not leaked, f"public mount resolves to internal relations: {leaked}"
+
+ internal_tables = _provider_tables(internal_module.api_)
+ assert internal_tables, "internal config exposed no provider-backed collections"
+ misrouted = {
+ name: table
+ for name, table in internal_tables.items()
+ if table and not table.startswith("ogc_internal_")
+ }
+ assert not misrouted, f"internal mount resolves to public relations: {misrouted}"
+
+
+def test_each_mount_advertises_its_own_server_url():
+ public_module, internal_module = _load_both()
+
+ public_url = public_module.api_.config["server"]["url"]
+ internal_url = internal_module.api_.config["server"]["url"]
+
+ assert public_url != internal_url
+ assert public_url == pygeoapi._server_url()
+ assert internal_url == pygeoapi._internal_server_url()
+
+
+def test_loading_a_mount_restores_config_env_vars():
+ # Leaving the last-loaded mount's paths in the environment would decide
+ # the config for anything that imports pygeoapi later in the process.
+ before = {key: os.environ.get(key) for key in pygeoapi._PYGEOAPI_ENV_KEYS}
- app = pygeoapi._load_pygeoapi_app()
+ _load_both()
- assert app is reloaded_module.APP
- assert reload_calls == [existing_module]
+ after = {key: os.environ.get(key) for key in pygeoapi._PYGEOAPI_ENV_KEYS}
+ assert after == before
From 72f26a56c349d59e16a3110eb54cec035eb4ee54 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Thu, 6 Aug 2026 10:00:22 -0600
Subject: [PATCH 037/151] feat(ogc): add public data disclaimer page
metadata.identification.terms_of_service has to resolve to something, and no NMBGMR disclaimer page exists to link to -- geoinfo.nmt.edu/disclaimer 404s and the Ocotillo site has no legal page -- so swapping the example.com placeholder for another URL would only move the problem.
Serving the page from this API keeps the text versioned and reviewable in git and gives a URL that resolves in every environment, rather than blocking on a page in the UI repo that nobody has published yet. Considered inlining the full text in terms_of_service instead, but OpenAPI 3.0 types info.termsOfService as a URI reference, so a multi-paragraph string there yields a spec-nonconforming document.
---
api/disclaimer.py | 113 +++++++++++++++++++++++++++++++++++++++
core/disclaimer.py | 47 ++++++++++++++++
core/initializers.py | 2 +
tests/test_disclaimer.py | 68 +++++++++++++++++++++++
4 files changed, 230 insertions(+)
create mode 100644 api/disclaimer.py
create mode 100644 core/disclaimer.py
create mode 100644 tests/test_disclaimer.py
diff --git a/api/disclaimer.py b/api/disclaimer.py
new file mode 100644
index 000000000..2e94981d1
--- /dev/null
+++ b/api/disclaimer.py
@@ -0,0 +1,113 @@
+# ===============================================================================
+# Copyright 2026
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Public data disclaimer page.
+
+Both pygeoapi mounts advertise this URL as
+`metadata.identification.terms_of_service`, so it is deliberately
+unauthenticated -- an OGC client following the advertised link has no
+credentials to present.
+
+HTML is the default because the pygeoapi landing page renders
+terms_of_service as a link a human clicks; JSON is offered for catalog
+harvesters that want the text as data rather than markup.
+"""
+
+import html
+from typing import Annotated
+
+from fastapi import APIRouter, Query, Request
+from fastapi.responses import HTMLResponse, JSONResponse
+
+from core.disclaimer import (
+ DISCLAIMER_CONTACT_EMAIL,
+ DISCLAIMER_PARAGRAPHS,
+ DISCLAIMER_TITLE,
+)
+
+router = APIRouter(tags=["disclaimer"])
+
+_STYLE = (
+ "max-width:44rem;margin:3rem auto;padding:0 1.25rem;"
+ "font-family:system-ui,-apple-system,'Segoe UI',sans-serif;"
+ "line-height:1.6;color:#1a1a1a"
+)
+
+
+def _wants_json(request: Request, f: str | None) -> bool:
+ # An explicit ?f= wins over content negotiation, matching pygeoapi's own
+ # precedence so the two surfaces behave the same way.
+ if f is not None:
+ return f.lower() == "json"
+ accept = request.headers.get("accept", "")
+ return "application/json" in accept and "text/html" not in accept
+
+
+def _render_html() -> str:
+ paragraphs = []
+ for paragraph in DISCLAIMER_PARAGRAPHS:
+ escaped = html.escape(paragraph)
+ escaped = escaped.replace(
+ DISCLAIMER_CONTACT_EMAIL,
+ f''
+ f"{DISCLAIMER_CONTACT_EMAIL} ",
+ )
+ paragraphs.append(f" {escaped}
")
+ body = "\n".join(paragraphs)
+ title = html.escape(DISCLAIMER_TITLE)
+ return (
+ "\n"
+ '\n'
+ " \n"
+ ' \n'
+ ' \n'
+ f" {title} | Ocotillo \n"
+ " \n"
+ f' \n'
+ f" {title} \n"
+ f"{body}\n"
+ " \n"
+ "\n"
+ )
+
+
+@router.get(
+ "/disclaimer",
+ response_class=HTMLResponse,
+ summary="Data disclaimer and terms of service",
+ responses={
+ 200: {
+ "content": {"text/html": {}, "application/json": {}},
+ "description": "The disclaimer as HTML (default) or JSON (?f=json).",
+ }
+ },
+)
+def get_disclaimer(
+ request: Request,
+ f: Annotated[
+ str | None,
+ Query(description="Response format. Use 'json' for the text as data."),
+ ] = None,
+):
+ if _wants_json(request, f):
+ return JSONResponse(
+ {
+ "title": DISCLAIMER_TITLE,
+ "paragraphs": list(DISCLAIMER_PARAGRAPHS),
+ "contact": DISCLAIMER_CONTACT_EMAIL,
+ }
+ )
+ return HTMLResponse(_render_html())
diff --git a/core/disclaimer.py b/core/disclaimer.py
new file mode 100644
index 000000000..3227074e6
--- /dev/null
+++ b/core/disclaimer.py
@@ -0,0 +1,47 @@
+# ===============================================================================
+# Copyright 2026
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Canonical text of the Ocotillo data disclaimer.
+
+The disclaimer is served at GET /disclaimer (api/disclaimer.py) and is the
+target of `metadata.identification.terms_of_service` in both pygeoapi configs.
+It lives here as plain constants rather than a template or a static file so
+that the HTML and JSON renderings cannot drift apart, and so it ships with the
+`core` package without any package-data wiring.
+"""
+
+DISCLAIMER_TITLE = "Disclaimer"
+
+DISCLAIMER_CONTACT_EMAIL = "ocotillo-nmbg@nmt.edu"
+
+DISCLAIMER_PARAGRAPHS: tuple[str, ...] = (
+ "These geospatial data are shared to help the public understand New "
+ "Mexico's geologic and water resources. All datasets have limitations, "
+ "particularly when combining data collected at different times, scales, "
+ "or for different purposes. Users should review the metadata for each "
+ "dataset and verify conditions on-site before making legal, regulatory, "
+ "or other high-consequence decisions. All geospatial datasets are "
+ "inherently scale-dependent.",
+ "The New Mexico Bureau of Geology and Mineral Resources (NMBGMR) provides "
+ "these data 'as-is' without warranties. NMBGMR does not guarantee the "
+ "accuracy, completeness, and timeliness of these data for any particular "
+ "purpose. Conditions may have changed since the data were collected. "
+ "Neither NMBGMR nor any partner agency providing data assumes liability "
+ "for any errors, omissions, or consequences arising from the use or "
+ "misuse of these data.",
+ "References to specific products or companies do not imply endorsement. "
+ "Proper citation of these data is appreciated. Questions or feedback: "
+ f"{DISCLAIMER_CONTACT_EMAIL}",
+)
diff --git a/core/initializers.py b/core/initializers.py
index 845d831dc..14a246cb4 100644
--- a/core/initializers.py
+++ b/core/initializers.py
@@ -215,10 +215,12 @@ def register_api_routes(app):
from api.geospatial import router as geospatial_router
from api.ngwmn import router as ngwmn_router
from api.feedback import router as feedback_router
+ from api.disclaimer import router as disclaimer_router
app.include_router(asset_router)
app.include_router(author_router)
app.include_router(contact_router)
+ app.include_router(disclaimer_router)
app.include_router(geospatial_router)
app.include_router(group_router)
app.include_router(lexicon_router)
diff --git a/tests/test_disclaimer.py b/tests/test_disclaimer.py
new file mode 100644
index 000000000..be694d4e6
--- /dev/null
+++ b/tests/test_disclaimer.py
@@ -0,0 +1,68 @@
+# ===============================================================================
+# Copyright 2026
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+from core.disclaimer import (
+ DISCLAIMER_CONTACT_EMAIL,
+ DISCLAIMER_PARAGRAPHS,
+ DISCLAIMER_TITLE,
+)
+from tests import client
+
+
+def test_disclaimer_html():
+ response = client.get("/disclaimer")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+
+ body = response.text
+ assert f"{DISCLAIMER_TITLE} " in body
+ assert f'href="mailto:{DISCLAIMER_CONTACT_EMAIL}"' in body
+ assert "New Mexico Bureau of Geology and Mineral Resources" in body
+ assert body.count("") == len(DISCLAIMER_PARAGRAPHS)
+
+
+def test_disclaimer_json():
+ response = client.get("/disclaimer", params={"f": "json"})
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+
+ payload = response.json()
+ assert payload["title"] == DISCLAIMER_TITLE
+ assert payload["contact"] == DISCLAIMER_CONTACT_EMAIL
+ assert payload["paragraphs"] == list(DISCLAIMER_PARAGRAPHS)
+
+
+def test_disclaimer_json_via_accept_header():
+ response = client.get("/disclaimer", headers={"Accept": "application/json"})
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+
+
+def test_disclaimer_html_wins_when_browser_accepts_both():
+ # Browsers send Accept: text/html,...,*/*, which must not be read as a
+ # request for the JSON representation.
+ response = client.get(
+ "/disclaimer",
+ headers={"Accept": "text/html,application/xhtml+xml,application/json;q=0.9"},
+ )
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+
+
+def test_disclaimer_requires_no_authentication():
+ # The pygeoapi configs advertise this URL as terms_of_service, so an OGC
+ # client following the link has no credentials to present.
+ response = client.get("/disclaimer")
+ assert response.status_code == 200
From 93206abee8a68aa4cfeba58669eb48b47e0fadc7 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Thu, 6 Aug 2026 11:08:34 -0600
Subject: [PATCH 038/151] feat(ogc): replace server metadata placeholders
Both configs carried example.com in four fields. The internal mount added under A11 postdates the audit, which names only the public file.
The change is narrower than the audit implies in one place and wider in another. provider.url was already https://geoinfo.nmt.edu -- the second example.com value is identification.url, a different field. And in pygeoapi 0.23.5 none of these fields appear on the JSON landing page, only in the OpenAPI document and the HTML landing page, with identification.url reaching JSON as the rel=about href.
terms_of_service is derived from PYGEOAPI_SERVER_URL by stripping the mount path rather than read from a new variable. PYGEOAPI_SERVER_URL is already set in app.template.yaml and all three CD workflows; a second base-URL variable would be a fourth place to get a deploy wrong.
provider.email is added and contact.role deliberately omitted, both to work around pygeoapi mapping quirks documented in comments at those lines.
---
core/pygeoapi-config-internal.yml | 18 ++++++++++++++----
core/pygeoapi-config.yml | 27 +++++++++++++++++++++------
core/pygeoapi.py | 24 ++++++++++++++++++++++++
3 files changed, 59 insertions(+), 10 deletions(-)
diff --git a/core/pygeoapi-config-internal.yml b/core/pygeoapi-config-internal.yml
index 7bfbb1590..f2ddb5001 100644
--- a/core/pygeoapi-config-internal.yml
+++ b/core/pygeoapi-config-internal.yml
@@ -23,18 +23,28 @@ metadata:
Authenticated internal OGC API - Features backed by PostGIS and
pygeoapi. Unlike the public /ogcapi mount, these collections are not
filtered by release_status and include private and draft records.
+ Provided without warranty - see the terms of service for data
+ limitations.
keywords: [features, ogcapi, postgis, pygeoapi, internal]
- terms_of_service: https://example.com/terms
- url: https://example.com
+ terms_of_service: {terms_of_service_url}
+ url: https://ocotillo.newmexicowaterdata.org
license:
name: CC-BY 4.0
url: https://creativecommons.org/licenses/by/4.0/
provider:
name: NMBGMR
url: https://geoinfo.nmt.edu
+ # pygeoapi builds OpenAPI info.contact from `provider`, not `contact`
+ # (pygeoapi/openapi.py gen_contact), so info.contact.email is empty
+ # without this line.
+ email: ocotillo-nmbg@nmt.edu
contact:
- name: API Support
- email: support@example.com
+ name: Ocotillo Support, NMBGMR
+ email: ocotillo-nmbg@nmt.edu
+ # No `role:` here. pygeoapi 0.23.5 writes contact.role into
+ # x-ogc-serviceContact.hoursOfService (pygeoapi/openapi.py, gen_contact --
+ # the line is a copy-paste of the `hours` branch above it), so setting it
+ # publishes "pointOfContact" as the service's hours of operation.
resources:
locations:
diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml
index ccae84eab..fb6fc6c2c 100644
--- a/core/pygeoapi-config.yml
+++ b/core/pygeoapi-config.yml
@@ -19,19 +19,34 @@ logging:
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
+ # The disclaimer pointer is repeated here because the JSON landing page
+ # carries only title/description/links -- terms_of_service below reaches
+ # the HTML landing page and the OpenAPI document, but not JSON clients.
+ description: >-
+ OGC API - Features service publishing New Mexico Bureau of Geology and
+ Mineral Resources groundwater, geochemistry, and monitoring-location
+ data. Provided without warranty - see the terms of service for data
+ limitations.
+ keywords: [features, ogcapi, postgis, pygeoapi, groundwater, new mexico]
+ terms_of_service: {terms_of_service_url}
+ url: https://ocotillo.newmexicowaterdata.org
license:
name: CC-BY 4.0
url: https://creativecommons.org/licenses/by/4.0/
provider:
name: NMBGMR
url: https://geoinfo.nmt.edu
+ # pygeoapi builds OpenAPI info.contact from `provider`, not `contact`
+ # (pygeoapi/openapi.py gen_contact), so info.contact.email is empty
+ # without this line.
+ email: ocotillo-nmbg@nmt.edu
contact:
- name: API Support
- email: support@example.com
+ name: Ocotillo Support, NMBGMR
+ email: ocotillo-nmbg@nmt.edu
+ # No `role:` here. pygeoapi 0.23.5 writes contact.role into
+ # x-ogc-serviceContact.hoursOfService (pygeoapi/openapi.py, gen_contact --
+ # the line is a copy-paste of the `hours` branch above it), so setting it
+ # publishes "pointOfContact" as the service's hours of operation.
resources:
locations:
diff --git a/core/pygeoapi.py b/core/pygeoapi.py
index e5bb3c34a..7a3a35126 100644
--- a/core/pygeoapi.py
+++ b/core/pygeoapi.py
@@ -5,6 +5,7 @@
import textwrap
from importlib.util import find_spec
from pathlib import Path
+from urllib.parse import urlparse
import yaml
from fastapi import FastAPI
@@ -198,6 +199,28 @@ def _internal_server_url() -> str:
return f"http://localhost:8000{_internal_mount_path()}"
+def _app_base_url() -> str:
+ # Derived from PYGEOAPI_SERVER_URL rather than a dedicated env var: that
+ # variable is already set in app.template.yaml and all three CD workflows,
+ # and a second base-URL variable would be a fourth place to get a deploy
+ # wrong. PYGEOAPI_SERVER_URL points at the mount (".../ogcapi"), so strip
+ # the mount path back off to recover the application root.
+ server_url = _server_url()
+ mount_path = _mount_path()
+ if server_url.endswith(mount_path):
+ return server_url[: -len(mount_path)].rstrip("/")
+ # Deployment where the advertised OGC URL is not simply
+ # (a rewriting proxy, say). Scheme + netloc is the best root available.
+ parsed = urlparse(server_url)
+ if parsed.scheme and parsed.netloc:
+ return f"{parsed.scheme}://{parsed.netloc}"
+ return server_url.rstrip("/")
+
+
+def _terms_of_service_url() -> str:
+ return f"{_app_base_url()}/disclaimer"
+
+
def _pygeoapi_dir(
runtime_dir_env: str = "PYGEOAPI_RUNTIME_DIR", default: str = "/tmp/pygeoapi"
) -> Path:
@@ -383,6 +406,7 @@ def _write_config(
)
config = template.format(
server_url=server_url,
+ terms_of_service_url=_terms_of_service_url(),
postgres_host=host,
postgres_port=port,
postgres_db=dbname,
From 14df42b2638050f71d6297902ddd45d845407501 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Thu, 6 Aug 2026 11:22:20 -0600
Subject: [PATCH 039/151] test(ogc): cover service metadata and disclaimer
The two @A2 scenarios have existed since the feature file was written but had no step definitions, and carried no tag that CI filters on, so nothing ran them and the placeholders survived unnoticed.
The second scenario is retargeted from the landing page to the OpenAPI document. In pygeoapi 0.23.5 the JSON landing page returns only title, description and links, so the provider and contact values it named could never have been asserted there as originally written.
terms_of_service is verified by resolving the advertised URL and looking for the disclaimer text, not by matching a literal table value. The value is environment-dependent, and an advertised URL that 404s is no better than a placeholder, which is the failure worth catching.
---
tests/features/ogc-cleanup-sprint1.feature | 27 +++--
tests/features/steps/ogc-cleanup-sprint1.py | 106 +++++++++++++++++++-
tests/test_ogc.py | 56 +++++++++++
3 files changed, 173 insertions(+), 16 deletions(-)
diff --git a/tests/features/ogc-cleanup-sprint1.feature b/tests/features/ogc-cleanup-sprint1.feature
index c8c325dad..6e3c1e71b 100644
--- a/tests/features/ogc-cleanup-sprint1.feature
+++ b/tests/features/ogc-cleanup-sprint1.feature
@@ -99,21 +99,26 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
# A2 — Replace OGC server metadata placeholders in pygeoapi-config.yml
# ---------------------------------------------------------------------------
- @backend @ogc-infrastructure @sprint-1 @high-priority @A2
+ @backend @ogc-infrastructure @sprint-1 @high-priority @A2 @production
Scenario: Service metadata contains no placeholder or example.com values
Given the service configuration has been updated with accurate metadata
- When a client requests the /ogcapi landing page
- Then the response body contains no "example.com" strings
+ When a client requests the /ogcapi landing page as JSON, as HTML, and as OpenAPI
+ Then no response body contains an "example.com" string
- @backend @ogc-infrastructure @sprint-1 @high-priority @A2 @wip
- Scenario: Landing page reflects correct contact and provider information
- When a client requests the /ogcapi landing page
+ @backend @ogc-infrastructure @sprint-1 @high-priority @A2 @production
+ Scenario: Service metadata reflects correct contact and provider information
+ When a client requests the /ogcapi OpenAPI document
Then the service metadata fields match the following values:
- | field | expected-value |
- | terms_of_service | TODO: confirm with technical lead |
- | provider_url | https://geoinfo.nmt.edu |
- | contact_name | TODO: confirm with technical lead |
- | contact_email | ocotillo-nmbg@nmt.edu |
+ | field | expected-value |
+ | provider_url | https://geoinfo.nmt.edu |
+ | contact_name | Ocotillo Support, NMBGMR |
+ | contact_email | ocotillo-nmbg@nmt.edu |
+ And the terms of service URL resolves to the service disclaimer page
+ # The three fields above are asserted against the OpenAPI document, not
+ # the JSON landing page: in pygeoapi 0.23.5 the JSON landing page carries
+ # only title, description, and links. terms_of_service is checked by
+ # resolving it, because an advertised URL that 404s is no better than a
+ # placeholder.
# ---------------------------------------------------------------------------
# A3 — Fix broken README example URLs
diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py
index 2b27e539f..6d701dedd 100644
--- a/tests/features/steps/ogc-cleanup-sprint1.py
+++ b/tests/features/steps/ogc-cleanup-sprint1.py
@@ -13,18 +13,20 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
-"""Step definitions for A1 (public release_status filter on ogc_* views) and
-A11 (authenticated internal OGC mount at /ogcapi-internal).
+"""Step definitions for A1 (public release_status filter on ogc_* views),
+A2 (OGC server metadata placeholders) and A11 (authenticated internal OGC
+mount at /ogcapi-internal).
-Only the @A1- and @A11-tagged scenarios in ogc-cleanup-sprint1.feature are
-implemented here. The other ~9 tickets sharing that feature file have no
-steps yet and stay undefined/dormant, per this ticket's plan.
+Only the @A1-, @A2- and @A11-tagged scenarios in ogc-cleanup-sprint1.feature
+are implemented here. The other ~8 tickets sharing that feature file have no
+steps yet and stay undefined/dormant, per those tickets' plans.
"""
import importlib
import os
from datetime import date
from unittest.mock import patch
+from urllib.parse import urlparse
from alembic import command
from behave import given, when, then
@@ -855,4 +857,98 @@ def step_then_no_collection_id_prefixed(context, prefix):
assert not offending, f"found collections with id prefixed {prefix!r}: {offending}"
+# ---------------------------------------------------------------------------
+# A2 -- Replace OGC server metadata placeholders in pygeoapi-config.yml
+# ---------------------------------------------------------------------------
+
+
+@given("the service configuration has been updated with accurate metadata")
+def step_given_service_metadata_updated(context):
+ # No-op marker: core/pygeoapi-config.yml is the artifact under test, so
+ # there is no runtime state to arrange. Mirrors how the A1/A11 givens
+ # treat already-applied state.
+ pass
+
+
+@when("a client requests the /ogcapi landing page as JSON, as HTML, and as OpenAPI")
+def step_when_request_landing_page_all_formats(context):
+ context.metadata_responses = {
+ "landing page (JSON)": context.client.get("/ogcapi", params={"f": "json"}),
+ "landing page (HTML)": context.client.get("/ogcapi", params={"f": "html"}),
+ "OpenAPI document": context.client.get("/ogcapi/openapi"),
+ }
+ for label, response in context.metadata_responses.items():
+ assert (
+ response.status_code == 200
+ ), f"{label} returned {response.status_code}, expected 200"
+
+
+@then('no response body contains an "{needle}" string')
+def step_then_no_response_contains(context, needle):
+ offending = [
+ label
+ for label, response in context.metadata_responses.items()
+ if needle in response.text
+ ]
+ assert not offending, f"{needle!r} still present in: {', '.join(offending)}"
+
+
+@when("a client requests the /ogcapi OpenAPI document")
+def step_when_request_openapi_document(context):
+ context.response = context.client.get("/ogcapi/openapi")
+ assert (
+ context.response.status_code == 200
+ ), f"/ogcapi/openapi returned {context.response.status_code}, expected 200"
+
+
+def _openapi_metadata_field(info, field):
+ # pygeoapi maps metadata.provider onto OpenAPI info.contact and
+ # metadata.contact onto the x-ogc-serviceContact extension
+ # (pygeoapi/openapi.py gen_contact) -- neither is on the JSON landing page.
+ contact = info["contact"]
+ service_contact = contact["x-ogc-serviceContact"]
+ if field == "provider_url":
+ return contact["url"]
+ if field == "contact_name":
+ return service_contact["name"]
+ if field == "contact_email":
+ return service_contact["emails"][0]["value"]
+ raise KeyError(f"unmapped metadata field {field!r}")
+
+
+@then("the service metadata fields match the following values:")
+def step_then_service_metadata_fields_match(context):
+ info = context.response.json()["info"]
+ mismatches = []
+ for row in context.table:
+ field = row["field"]
+ expected = row["expected-value"]
+ actual = _openapi_metadata_field(info, field)
+ if actual != expected:
+ mismatches.append(f"{field}: expected {expected!r}, got {actual!r}")
+ assert not mismatches, "; ".join(mismatches)
+
+
+@then("the terms of service URL resolves to the service disclaimer page")
+def step_then_terms_of_service_resolves(context):
+ terms_url = context.response.json()["info"]["termsOfService"]
+ parsed = urlparse(terms_url)
+ assert parsed.scheme in (
+ "http",
+ "https",
+ ), f"termsOfService {terms_url!r} is not an absolute http(s) URL"
+ assert (
+ parsed.path == "/disclaimer"
+ ), f"termsOfService {terms_url!r} does not point at /disclaimer"
+
+ response = context.client.get(parsed.path)
+ assert response.status_code == 200, (
+ f"advertised termsOfService {terms_url!r} returned "
+ f"{response.status_code} -- a 404 is no better than a placeholder"
+ )
+ assert (
+ "New Mexico Bureau of Geology and Mineral Resources" in response.text
+ ), f"{terms_url!r} resolved but does not look like the disclaimer page"
+
+
# ============= EOF =============================================
diff --git a/tests/test_ogc.py b/tests/test_ogc.py
index f711b9caa..5385d243d 100644
--- a/tests/test_ogc.py
+++ b/tests/test_ogc.py
@@ -15,6 +15,7 @@
# ===============================================================================
from datetime import date, datetime
from importlib.util import find_spec
+from urllib.parse import urlparse
import pytest
from fastapi.testclient import TestClient
@@ -95,6 +96,61 @@ def test_ogc_openapi_has_paths(ogc_client):
assert "/collections" in payload["paths"]
+# A2: every surface that echoes metadata from core/pygeoapi-config.yml.
+# The JSON landing page carries only title/description/links, so the
+# provider/contact/terms assertions below have to go through the OpenAPI
+# document -- see pygeoapi.api.landing_page vs pygeoapi.openapi.get_oas_30.
+@pytest.mark.parametrize(
+ "path,params",
+ [
+ ("/ogcapi", {"f": "json"}),
+ ("/ogcapi", {"f": "html"}),
+ ("/ogcapi/openapi", {}),
+ ("/ogcapi/collections", {"f": "json"}),
+ ],
+)
+def test_ogc_metadata_has_no_placeholders(ogc_client, path, params):
+ response = ogc_client.get(path, params=params)
+ assert response.status_code == 200
+ assert "example.com" not in response.text
+
+
+def test_ogc_openapi_contact_metadata(ogc_client):
+ response = ogc_client.get("/ogcapi/openapi?f=json")
+ assert response.status_code == 200
+ info = response.json()["info"]
+
+ # info.contact is built from metadata.provider, and metadata.contact is
+ # nested under the x-ogc-serviceContact extension.
+ assert info["contact"]["name"] == "NMBGMR"
+ assert info["contact"]["url"] == "https://geoinfo.nmt.edu"
+ assert info["contact"]["email"] == "ocotillo-nmbg@nmt.edu"
+
+ service_contact = info["contact"]["x-ogc-serviceContact"]
+ assert service_contact["name"] == "Ocotillo Support, NMBGMR"
+ assert service_contact["emails"][0]["value"] == "ocotillo-nmbg@nmt.edu"
+
+
+def test_ogc_terms_of_service_resolves(ogc_client):
+ response = ogc_client.get("/ogcapi/openapi?f=json")
+ terms_url = response.json()["info"]["termsOfService"]
+ parsed = urlparse(terms_url)
+ assert parsed.scheme in ("http", "https")
+ assert parsed.path == "/disclaimer"
+
+ # An advertised terms_of_service that 404s is no better than a placeholder.
+ disclaimer = ogc_client.get(parsed.path)
+ assert disclaimer.status_code == 200
+ assert "New Mexico Bureau of Geology and Mineral Resources" in disclaimer.text
+
+
+def test_ogc_landing_page_advertises_service_url(ogc_client):
+ response = ogc_client.get("/ogcapi", params={"f": "json"})
+ about = [link for link in response.json()["links"] if link["rel"] == "about"]
+ assert about, "landing page has no rel=about link"
+ assert about[0]["href"] == "https://ocotillo.newmexicowaterdata.org"
+
+
def test_latest_tds_observation_date_falls_back_to_collection_date(water_well_thing):
with session_ctx() as session:
csi = NMA_Chemistry_SampleInfo(
From 3f10ddb8412afb7b01f3d1558c895e65be63d03a Mon Sep 17 00:00:00 2001
From: jakeross
Date: Fri, 7 Aug 2026 01:08:05 -0700
Subject: [PATCH 040/151] refactor(domain): extract CSV importer rules into a
domain layer
services/ was documented as "business logic and database interactions" and
did both in the same functions, so rules could not be exercised without a
database and drifted between callers. The groundwater-level sample name was
written out three times across two files; the field staff contact lookup had
two different WHERE clauses.
Add a domain/ package holding those rules as plain functions over plain
values. Modules there import nothing from api/, db/, schemas/, or services/,
and no fastapi, sqlalchemy, pydantic, or httpx. services/ keeps its
orchestration role: load rows, call the rule, persist the result.
- domain/units.py holds the foot/meter conversions, moved out of
services/util.py, which re-exports them so existing imports keep working.
Importing them from services/util.py previously dragged in httpx, pyproj,
and SQLAlchemy.
- domain/wells.py, domain/water_levels.py, domain/samples.py,
domain/field_staff.py, and domain/values.py hold the rules extracted from
the two CSV importers.
- Domain errors subclass ValueError, because the importers already treat a
ValueError raised on a row as a per-row validation failure.
Both importers keep every existing function signature, so the tests that
import their private helpers still work. 67 new tests cover the extracted
rules with no database and no fixtures.
Aligning the two field staff contact lookups fixes a defect: well inventory
also filtered on contact_type, so it missed an existing contact created with
a different type and then failed on the duplicate insert. Contact enforces
uniqueness on (name, organization), and both paths now use that key. This
changes import behavior -- well inventory now reuses a contact where it
previously errored.
See ADR4.md for the layering rationale and for what was deliberately left
alone.
Co-Authored-By: Claude Opus 5
---
ADR4.md | 80 +++++++++
CLAUDE.md | 19 +-
domain/__init__.py | 30 ++++
domain/field_staff.py | 68 ++++++++
domain/samples.py | 39 +++++
domain/units.py | 45 +++++
domain/values.py | 56 ++++++
domain/water_levels.py | 98 +++++++++++
domain/wells.py | 214 +++++++++++++++++++++++
services/util.py | 25 ++-
services/water_level_csv.py | 90 ++++------
services/well_inventory_csv.py | 276 ++++++++++--------------------
tests/test_domain_values.py | 109 ++++++++++++
tests/test_domain_water_levels.py | 138 +++++++++++++++
tests/test_domain_wells.py | 210 +++++++++++++++++++++++
tests/test_well_inventory.py | 32 ++--
16 files changed, 1256 insertions(+), 273 deletions(-)
create mode 100644 ADR4.md
create mode 100644 domain/__init__.py
create mode 100644 domain/field_staff.py
create mode 100644 domain/samples.py
create mode 100644 domain/units.py
create mode 100644 domain/values.py
create mode 100644 domain/water_levels.py
create mode 100644 domain/wells.py
create mode 100644 tests/test_domain_values.py
create mode 100644 tests/test_domain_water_levels.py
create mode 100644 tests/test_domain_wells.py
diff --git a/ADR4.md b/ADR4.md
new file mode 100644
index 000000000..00bd92cdb
--- /dev/null
+++ b/ADR4.md
@@ -0,0 +1,80 @@
+# ADR4: A Domain Layer for Import Rules
+
+## Status
+
+Accepted, partially applied. The `domain/` package exists and the two CSV
+importers use it. The rest of `services/` is untouched and stays that way until
+someone has a reason to open those files.
+
+## Context
+
+`services/` is documented as "business logic and database interactions", and it
+does both in the same functions. The clearest example is
+`services/well_inventory_csv.py`: a single call to `_add_csv_row` mixed unit
+conversion, cross-column validation, note formatting, and `session.add(...)`.
+
+Three consequences:
+
+1. **Rules could not be tested without a database.** Verifying that a
+ measuring point height conflict is rejected meant standing up PostGIS,
+ building a `Thing`, and running an import.
+2. **Rules drifted between callers.** The groundwater-level sample name was
+ written out three times across two files. The foot/meter conversion was
+ duplicated until BDMS-284 consolidated it. Field staff contact lookup had
+ two different WHERE clauses, one of which was wrong (see below).
+3. **There was no obvious home for a new rule.** `services/util.py` had quietly
+ become one — it holds the unit conversions — but nothing named it as such, so
+ the next rule went wherever it was first needed.
+
+## Decision
+
+Add a `domain/` package holding business rules as plain functions over plain
+values. Modules there import nothing from `api/`, `db/`, `schemas/`, or
+`services/`, and no `fastapi`, `sqlalchemy`, `pydantic`, or `httpx`.
+
+`services/` keeps its orchestration role: load rows, call the rule, persist the
+result, translate errors into the transport's shape.
+
+Domain errors subclass `ValueError`, because the importers already treat a
+`ValueError` raised while handling a row as a per-row validation failure rather
+than an aborted run.
+
+### What we did *not* decide
+
+This is not an adoption of hexagonal architecture or DDD. There are no entities,
+repositories, aggregates, or mapping layers, and `services/` still talks to
+SQLAlchemy models directly. The cost of a full restructure is not justified at
+this size, and a half-applied one — domain objects that quietly hold a session —
+is worse than none.
+
+Extraction is opportunistic: when you open an importer to change a rule, move
+the rule. There is no migration plan for the remaining service modules.
+
+## Consequences
+
+**Good.** The extracted rules have 67 tests that need no database and run in
+seconds. `services/util.py` no longer has to be imported to convert feet to
+meters, which previously dragged in `httpx`, `pyproj`, and SQLAlchemy.
+
+**Cost.** One more package, and a rule now lives one call away from where it is
+used. For a rule with a single caller this is pure overhead; extract when a rule
+is shared, subtle, or expensive to test in place, not by default.
+
+**Watch for.** `services/util.py` re-exports the unit conversions for backwards
+compatibility. That re-export is a transition aid, not a pattern — new code
+should import from `domain.units`.
+
+## Notes
+
+Aligning the two field-staff contact lookups surfaced a real defect.
+`services/water_level_csv.py` looked contacts up on `(name, organization)` with a
+comment explaining that `Contact` enforces uniqueness on exactly that pair, while
+`services/well_inventory_csv.py` also filtered on `contact_type`. The second form
+misses an existing contact created with a different type and then fails on the
+duplicate insert. Both now use the `(name, organization)` key.
+
+Two remaining copies of the enum-unwrapping idiom in
+`services/well_inventory_csv.py` (`groundwater_level_reason`, `nma_data_quality`)
+were left alone: each treats a falsy non-enum value slightly differently from
+`domain.values.enum_value`, and reconciling them is a behavior change that wants
+its own ticket.
diff --git a/CLAUDE.md b/CLAUDE.md
index d193e6a7c..77cb84105 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -117,8 +117,9 @@ Location (geographic point)
├── db/ # SQLAlchemy models (one file per table/resource)
│ ├── engine.py # Database connection configuration
│ └── ...
+├── domain/ # Business rules as plain functions (no DB, no HTTP)
├── schemas/ # Pydantic schemas (validation, serialization)
-├── services/ # Business logic and database interactions
+├── services/ # Orchestration: load, call domain rules, persist
├── tests/ # Pytest test suite
│ ├── conftest.py # Shared fixtures (test data setup)
│ └── __init__.py # Sets test database (ocotilloapi_test)
@@ -129,6 +130,22 @@ Location (geographic point)
└── main.py # Application entry point
```
+### Domain Rules
+
+`domain/` holds business rules as plain functions over plain values -- unit
+conversion, cross-column validation, deterministic naming. Modules there import
+nothing from `api/`, `db/`, `schemas/`, or `services/`, and no `fastapi`,
+`sqlalchemy`, `pydantic`, or `httpx`, so the rules are testable without a
+database.
+
+`services/` loads the data, calls the rule, and persists the result. Domain
+errors subclass `ValueError` because the CSV importers treat a `ValueError`
+raised on a row as a per-row validation failure.
+
+Extraction is opportunistic, not a migration: move a rule into `domain/` when
+you are already editing it and it is shared, subtle, or awkward to test in
+place. Read **`ADR4.md`** before extending the layer.
+
### Authentication & Authorization
The system uses **Authentik** for OAuth2 authentication with role-based access control:
diff --git a/domain/__init__.py b/domain/__init__.py
new file mode 100644
index 000000000..49d8c64b9
--- /dev/null
+++ b/domain/__init__.py
@@ -0,0 +1,30 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Domain rules: business knowledge expressed as plain Python.
+
+Modules in this package must not import ``fastapi``, ``sqlalchemy``, ``pydantic``,
+``httpx``, or anything from ``api/``, ``db/``, ``schemas/``, or ``services/``.
+That restriction is the point: everything here is callable, and testable, without
+a database session, an HTTP request, or a network round trip.
+
+Callers in ``services/`` are responsible for loading data, calling into these
+rules, and persisting the result.
+
+See ``ADR4.md`` for the layering rationale.
+"""
+
+# ============= EOF =============================================
diff --git a/domain/field_staff.py b/domain/field_staff.py
new file mode 100644
index 000000000..6bc00e6ef
--- /dev/null
+++ b/domain/field_staff.py
@@ -0,0 +1,68 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Field staff rules shared by the CSV importers.
+
+Both importers read the same three fixed staff columns and both create the same
+kind of contact for a name they have not seen before. Keeping the roles and the
+contact defaults here stops the two from drifting.
+"""
+
+LEAD_ROLE = "Lead"
+PARTICIPANT_ROLE = "Participant"
+
+FIELD_STAFF_CONTACT_TYPE = "Field Event Participant"
+FIELD_STAFF_ORGANIZATION = "NMBGMR"
+FIELD_STAFF_CONTACT_ROLE = "Technician"
+
+
+def field_staff_entries(
+ lead: str | None,
+ second: str | None,
+ third: str | None,
+) -> tuple[tuple[str, str], ...]:
+ """
+ Normalize the three fixed staff columns into ``(name, role)`` pairs.
+
+ The first column is the lead; the other two are participants. Blank columns
+ are dropped, so a row that names only a lead yields a single entry.
+ """
+ specs = (
+ (lead, LEAD_ROLE),
+ (second, PARTICIPANT_ROLE),
+ (third, PARTICIPANT_ROLE),
+ )
+ return tuple((name, role) for name, role in specs if name)
+
+
+def field_staff_contact_payload(name: str) -> dict:
+ """
+ Build the contact payload used when an imported staff name has no contact yet.
+
+ Callers must look the contact up on ``(name, organization)`` -- the pair
+ ``Contact`` enforces uniqueness on. Including ``contact_type`` in the lookup
+ misses an existing row that was created with a different type and then fails
+ on the duplicate insert.
+ """
+ return {
+ "name": name,
+ "role": FIELD_STAFF_CONTACT_ROLE,
+ "organization": FIELD_STAFF_ORGANIZATION,
+ "contact_type": FIELD_STAFF_CONTACT_TYPE,
+ }
+
+
+# ============= EOF =============================================
diff --git a/domain/samples.py b/domain/samples.py
new file mode 100644
index 000000000..27cbc2f3e
--- /dev/null
+++ b/domain/samples.py
@@ -0,0 +1,39 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Sample naming rules."""
+
+from datetime import datetime
+
+WATER_LEVEL_SAMPLE_TOKEN = "WL"
+WATER_LEVEL_SAMPLE_TIMESTAMP_FORMAT = "%Y%m%d%H%M"
+
+
+def water_level_sample_name(well_name: str, measured_at: datetime) -> str:
+ """
+ Build the deterministic sample identifier for a groundwater-level measurement.
+
+ Both CSV importers use this name to decide whether a measurement has already
+ been imported, so the two must agree exactly: the well inventory importer
+ writes the name and later looks a well up by it, while the water level
+ importer matches on it to update in place instead of inserting a duplicate.
+ A drift between the two formats would silently turn every re-import into a
+ new sample.
+ """
+ stamp = measured_at.strftime(WATER_LEVEL_SAMPLE_TIMESTAMP_FORMAT)
+ return f"{well_name}-{WATER_LEVEL_SAMPLE_TOKEN}-{stamp}"
+
+
+# ============= EOF =============================================
diff --git a/domain/units.py b/domain/units.py
new file mode 100644
index 000000000..66fd5d917
--- /dev/null
+++ b/domain/units.py
@@ -0,0 +1,45 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Unit conversion.
+
+This is the single definition of the foot/meter relationship for application
+code. ``services/util.py`` re-exports these names, so existing imports continue
+to work; new code should import from here.
+
+Alembic revisions deliberately keep their own copy of the constant. A migration
+must reproduce the arithmetic it ran with at the time it was written, so it
+cannot track a moving import.
+"""
+
+METERS_TO_FEET = 3.28084
+
+
+def convert_ft_to_m(feet: float | None, ndigits: int = 6) -> float | None:
+ """Convert a length from feet to meters."""
+ if feet is None:
+ return None
+ return round(feet / METERS_TO_FEET, ndigits)
+
+
+def convert_m_to_ft(meters: float | None, ndigits: int = 6) -> float | None:
+ """Convert a length from meters to feet."""
+ if meters is None:
+ return None
+ return round(meters * METERS_TO_FEET, ndigits)
+
+
+# ============= EOF =============================================
diff --git a/domain/values.py b/domain/values.py
new file mode 100644
index 000000000..eba6d1ea5
--- /dev/null
+++ b/domain/values.py
@@ -0,0 +1,56 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Small value helpers shared by the domain rules."""
+
+from typing import Any
+
+
+def enum_value(value: Any, default: Any = None) -> Any:
+ """
+ Unwrap an ``Enum``-like value to its ``.value``.
+
+ CSV rows reach the importers with fields that may be a validated enum member
+ or a bare string, depending on which Pydantic schema produced them, so the
+ ``x.value if hasattr(x, "value") else x`` idiom was repeated at roughly a
+ dozen call sites.
+
+ Non-enum values pass through unchanged. When ``default`` is supplied, a falsy
+ non-enum value (``None``, ``""``) is replaced by it; when ``default`` is
+ omitted, falsy values are returned as-is.
+ """
+ if hasattr(value, "value"):
+ return value.value
+ if default is not None and not value:
+ return default
+ return value
+
+
+def build_notes(candidates) -> list[dict]:
+ """
+ Turn ``(content, note_type)`` pairs into note payloads, dropping empty content.
+
+ ``candidates`` is any iterable of two-tuples. Order is preserved, and a pair
+ whose content is ``None`` is skipped -- an empty string is *not* skipped,
+ matching the importers' existing ``is not None`` check.
+ """
+ return [
+ {"content": content, "note_type": note_type}
+ for content, note_type in candidates
+ if content is not None
+ ]
+
+
+# ============= EOF =============================================
diff --git a/domain/water_levels.py b/domain/water_levels.py
new file mode 100644
index 000000000..5939a51f1
--- /dev/null
+++ b/domain/water_levels.py
@@ -0,0 +1,98 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Water level rules used when importing measurement spreadsheets.
+
+The importer resolves the well and its measuring point history from the database
+and then hands the plain numbers to these functions. Messages are returned
+without a row prefix; the caller adds ``Row N:`` so the same rule can be reported
+from a per-row importer or from a single-record API call.
+"""
+
+MEASUREMENT_UNIT = "ft"
+SAMPLE_MATRIX = "groundwater"
+SAMPLE_QC_TYPE = "Normal"
+GROUNDWATER_LEVEL_ACTIVITY_TYPE = "groundwater level"
+
+
+def reconcile_measuring_point_height(
+ csv_mp_height: float | None,
+ existing_mp_height: float | int | None,
+) -> tuple[float | int | None, float | int | None, bool]:
+ """
+ Decide which measuring point height applies to a measurement.
+
+ Returns ``(resolved, existing, differs)``. A height given in the CSV wins over
+ the well's recorded history, because the field crew measured it on the day of
+ the reading; ``differs`` reports that the two disagreed so the caller can warn
+ without rejecting the row.
+
+ ``existing_mp_height`` arrives as whatever the database column yields, often a
+ ``Decimal``, and is coerced to ``float`` so callers compare and render like
+ values.
+ """
+ if existing_mp_height is not None:
+ existing_mp_height = float(existing_mp_height)
+
+ if csv_mp_height is not None:
+ differs = existing_mp_height is not None and csv_mp_height != existing_mp_height
+ return csv_mp_height, existing_mp_height, differs
+
+ return existing_mp_height, existing_mp_height, False
+
+
+def measuring_point_height_conflict_message(
+ csv_mp_height: float | None,
+ existing_mp_height: float | int | None,
+) -> str:
+ """Describe a CSV height that overrides a different recorded height."""
+ return (
+ f"CSV mp_height ({csv_mp_height}) differs from existing measuring point "
+ f"height ({existing_mp_height}); CSV value will be used"
+ )
+
+
+def depth_to_water_error(
+ depth_to_water_ft: float | None,
+ resolved_mp_height: float | int | None,
+ well_depth: float | int | None,
+) -> str | None:
+ """
+ Reject a reading that puts the water table below the bottom of the well.
+
+ ``depth_to_water_ft`` is measured from the measuring point, which sits above
+ the ground surface, while ``well_depth`` is measured from the ground surface,
+ so the two are only comparable after subtracting the measuring point height.
+
+ Returns ``None`` when the check does not apply -- any of the three inputs may
+ be missing, and an unknown well depth is not evidence of a bad reading.
+ """
+ if depth_to_water_ft is None or resolved_mp_height is None or well_depth is None:
+ return None
+
+ well_depth = float(well_depth)
+ corrected_depth_to_water = depth_to_water_ft - resolved_mp_height
+ if corrected_depth_to_water >= well_depth:
+ return (
+ f"depth_to_water_ft minus measuring point height "
+ f"({corrected_depth_to_water}) must be less than well depth "
+ f"({well_depth})"
+ )
+
+ return None
+
+
+# ============= EOF =============================================
diff --git a/domain/wells.py b/domain/wells.py
new file mode 100644
index 000000000..fcabf0ba4
--- /dev/null
+++ b/domain/wells.py
@@ -0,0 +1,214 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Well rules used when importing the well inventory spreadsheet.
+
+Every function here takes plain values and returns plain values, so the rules can
+be exercised without a database. ``services/well_inventory_csv.py`` supplies the
+values from a validated ``WellInventoryRow`` and persists whatever comes back.
+
+Errors subclass ``ValueError`` because the importer already treats a ``ValueError``
+raised while handling a row as a per-row validation failure rather than an aborted
+import.
+"""
+
+import re
+
+from core.constants import SRID_UTM_ZONE_12N, SRID_UTM_ZONE_13N
+from domain.units import convert_ft_to_m
+from domain.values import enum_value
+
+AUTOGEN_DEFAULT_PREFIX = "NM-"
+AUTOGEN_PREFIX_REGEX = re.compile(r"^[A-Z]{2,3}-$", re.IGNORECASE)
+AUTOGEN_TOKEN_REGEX = re.compile(
+ r"^(?P[A-Z]{2,3})\s*-\s*(?:x{4}|X{4})$", re.IGNORECASE
+)
+
+# TODO: this needs to be more sophisticated in the future. Likely more than 13N
+# and 12N will be used.
+UTM_ZONE_SRIDS = {
+ "13N": SRID_UTM_ZONE_13N,
+ "12N": SRID_UTM_ZONE_12N,
+}
+
+RELEASE_STATUS_PUBLIC = "public"
+RELEASE_STATUS_PRIVATE = "private"
+RELEASE_STATUS_DRAFT = "draft"
+
+UNKNOWN_DEPTH_SOURCE = "unknown"
+
+SITE_NAME_ORGANIZATION = "NMBGMR"
+OSE_WELL_RECORD_ORGANIZATION = "NMOSE"
+ALTERNATE_ID_RELATION = "same_as"
+
+
+class UnsupportedUtmZone(ValueError):
+ """Raised when a row carries a UTM zone the importer cannot project from."""
+
+
+class ConflictingMeasuringPointHeight(ValueError):
+ """Raised when a row gives two different measuring point heights."""
+
+
+def autogen_prefix(well_id: str | None) -> str | None:
+ """
+ Return the normalized auto-generation prefix for a placeholder well id.
+
+ Returns ``None`` when the value is a real well id and should be used as-is.
+
+ Supported placeholder forms:
+
+ - ``XY-`` / ``ABC-`` -- a bare 2-3 letter prefix
+ - ``WL-XXXX`` / ``SAC-xxxx`` -- a prefix with a placeholder number, with
+ optional spaces around the dash
+ - blank -- uses the default ``NM-`` prefix
+ """
+ value = (well_id or "").strip()
+
+ if not value:
+ return AUTOGEN_DEFAULT_PREFIX
+
+ if AUTOGEN_PREFIX_REGEX.match(value):
+ return f"{value[:-1].upper()}-"
+
+ match = AUTOGEN_TOKEN_REGEX.match(value)
+ if match:
+ return f"{match.group('prefix').upper()}-"
+
+ return None
+
+
+def srid_for_utm_zone(utm_zone: str | None) -> int:
+ """Return the EPSG code for a supported UTM zone label."""
+ try:
+ return UTM_ZONE_SRIDS[utm_zone]
+ except KeyError:
+ raise UnsupportedUtmZone(f"Unsupported UTM zone: {utm_zone}") from None
+
+
+def elevation_m_from_ft(elevation_ft: float | str | None) -> float:
+ """
+ Convert a reported elevation to the meters the ``Location`` row stores.
+
+ A missing elevation becomes ``0.0`` rather than ``NULL``: ``Location.elevation``
+ is not nullable, and the inventory sheet leaves the column blank for wells
+ whose elevation has not been surveyed yet.
+ """
+ if elevation_ft is None:
+ return 0.0
+ return convert_ft_to_m(float(elevation_ft))
+
+
+def release_status(public_availability_acknowledgement: bool | None) -> str:
+ """
+ Map the public-availability acknowledgement to a location release status.
+
+ The acknowledgement is deliberately three-state. An unanswered question is
+ not the same as a refusal, so it holds the location in ``draft`` instead of
+ publishing or hiding it.
+ """
+ if public_availability_acknowledgement is True:
+ return RELEASE_STATUS_PUBLIC
+ if public_availability_acknowledgement is False:
+ return RELEASE_STATUS_PRIVATE
+ return RELEASE_STATUS_DRAFT
+
+
+def resolve_measuring_point_height(
+ mp_height: float | None,
+ measuring_point_height_ft: float | None,
+) -> float | None:
+ """
+ Reconcile the two columns that can carry a measuring point height.
+
+ The sheet grew a second spelling of the same measurement. Either may be
+ supplied, but when both are they must agree -- guessing which one is
+ authoritative would silently bias every water level computed against it.
+ """
+ if (
+ mp_height is not None
+ and measuring_point_height_ft is not None
+ and mp_height != measuring_point_height_ft
+ ):
+ raise ConflictingMeasuringPointHeight(
+ "Conflicting values for measuring point height: "
+ "mp_height and measuring_point_height_ft"
+ )
+
+ if measuring_point_height_ft is not None:
+ return measuring_point_height_ft
+ return mp_height
+
+
+def historic_depth_to_water_source(depth_source) -> str:
+ """
+ Return the source to credit for a historic depth-to-water reading.
+
+ Developer's note: Laila said the depth source is almost always the source for
+ the historic depth to water, and that reusing it here is acceptable.
+ """
+ if not depth_source:
+ return UNKNOWN_DEPTH_SOURCE
+ return str(enum_value(depth_source)).lower()
+
+
+def historic_depth_to_water_note(
+ historic_depth_to_water_ft: float | None,
+ depth_source,
+) -> str | None:
+ """
+ Render the historic depth-to-water note, or ``None`` when there is no reading.
+
+ The value is recorded as a note rather than a measurement because it is
+ hearsay from the well owner, not something the field crew observed.
+ """
+ if historic_depth_to_water_ft is None:
+ return None
+ source = historic_depth_to_water_source(depth_source)
+ return (
+ f"historic depth to water: {historic_depth_to_water_ft} ft - source: {source}"
+ )
+
+
+def well_purposes(*purposes) -> list:
+ """Collapse the fixed well-purpose columns into a list, dropping blanks."""
+ return [purpose for purpose in purposes if purpose]
+
+
+def alternate_ids(site_name: str | None, ose_well_record_id: str | None) -> list[dict]:
+ """
+ Build the alternate-id payloads for the identifiers other agencies use.
+
+ ``thing_id`` is a placeholder; the caller replaces it once the ``Thing`` has
+ been flushed and has an id.
+ """
+ pairs = (
+ (site_name, SITE_NAME_ORGANIZATION),
+ (ose_well_record_id, OSE_WELL_RECORD_ORGANIZATION),
+ )
+ return [
+ {
+ "thing_id": -1,
+ "alternate_id": alternate_id,
+ "alternate_organization": organization,
+ "relation": ALTERNATE_ID_RELATION,
+ }
+ for alternate_id, organization in pairs
+ if alternate_id is not None
+ ]
+
+
+# ============= EOF =============================================
diff --git a/services/util.py b/services/util.py
index aeeaae807..dbf88f98e 100644
--- a/services/util.py
+++ b/services/util.py
@@ -11,8 +11,17 @@
from core.constants import SRID_WGS84
+# Re-exported so the many existing ``from services.util import convert_ft_to_m``
+# imports keep working. The definitions live in ``domain/units.py``; importing
+# them from here drags in httpx, pyproj, and SQLAlchemy, which is exactly what
+# the domain layer exists to avoid.
+from domain.units import ( # noqa: F401
+ METERS_TO_FEET,
+ convert_ft_to_m,
+ convert_m_to_ft,
+)
+
TRANSFORMERS = {}
-METERS_TO_FEET = 3.28084
DEFAULT_HTTP_TIMEOUT = 10.0
DEFAULT_HTTP_RETRIES = 3
DEFAULT_HTTP_BACKOFF = 0.5
@@ -120,20 +129,6 @@ def convert_dt_tz_naive_to_tz_aware(
return dt_aware
-def convert_ft_to_m(feet: float | None, ndigits: int = 6) -> float | None:
- """Convert a length from feet to meters."""
- if feet is None:
- return None
- return round(feet / METERS_TO_FEET, ndigits)
-
-
-def convert_m_to_ft(meters: float | None, ndigits: int = 6) -> float | None:
- """Convert a length from meters to feet."""
- if meters is None:
- return None
- return round(meters * METERS_TO_FEET, ndigits)
-
-
def get_tiger_data(
lon: float, lat: float, layer: int, outfields: str = "*"
) -> dict | None:
diff --git a/services/water_level_csv.py b/services/water_level_csv.py
index a9f4198d0..848db529a 100644
--- a/services/water_level_csv.py
+++ b/services/water_level_csv.py
@@ -35,6 +35,21 @@
FieldEventParticipant,
)
from db.engine import session_ctx
+from domain.field_staff import (
+ FIELD_STAFF_ORGANIZATION,
+ field_staff_contact_payload,
+ field_staff_entries,
+)
+from domain.samples import water_level_sample_name
+from domain.water_levels import (
+ GROUNDWATER_LEVEL_ACTIVITY_TYPE,
+ MEASUREMENT_UNIT,
+ SAMPLE_MATRIX,
+ SAMPLE_QC_TYPE,
+ depth_to_water_error,
+ measuring_point_height_conflict_message,
+ reconcile_measuring_point_height,
+)
from pydantic import ValidationError
from schemas.water_level_csv import (
WaterLevelCsvRow,
@@ -323,30 +338,16 @@ def _normalize_field_staff_entries(
model: WaterLevelCsvRow,
) -> tuple[tuple[str, str], ...]:
"""Normalize fixed staff columns into an iterable participant list."""
- participant_specs = (
- (model.field_staff, "Lead"),
- (model.field_staff_2, "Participant"),
- (model.field_staff_3, "Participant"),
- )
- return tuple(
- (staff_name, role) for staff_name, role in participant_specs if staff_name
+ return field_staff_entries(
+ model.field_staff, model.field_staff_2, model.field_staff_3
)
def _resolve_measuring_point_height(
well: Thing, csv_mp_height: float | None
) -> tuple[float | int | None, float | int | None, bool]:
- existing_mp_height = well.measuring_point_height
- if existing_mp_height is not None:
- existing_mp_height = float(existing_mp_height)
- if csv_mp_height is not None:
- return (
- csv_mp_height,
- existing_mp_height,
- (existing_mp_height is not None and csv_mp_height != existing_mp_height),
- )
-
- return existing_mp_height, existing_mp_height, False
+ """Read the well's recorded height and apply the reconciliation rule."""
+ return reconcile_measuring_point_height(csv_mp_height, well.measuring_point_height)
def _validate_depth_to_water_against_well(
@@ -355,22 +356,11 @@ def _validate_depth_to_water_against_well(
depth_to_water_ft: float | None,
resolved_mp_height: float | int | None,
) -> str | None:
- well_depth = well.well_depth
- if well_depth is not None:
- well_depth = float(well_depth)
-
- if depth_to_water_ft is None or resolved_mp_height is None or well_depth is None:
+ """Apply the depth-to-water rule to a well, tagging any message with its row."""
+ error = depth_to_water_error(depth_to_water_ft, resolved_mp_height, well.well_depth)
+ if error is None:
return None
-
- corrected_depth_to_water = depth_to_water_ft - resolved_mp_height
- if corrected_depth_to_water >= well_depth:
- return (
- f"Row {row_index}: depth_to_water_ft minus measuring point height "
- f"({corrected_depth_to_water}) must be less than well depth "
- f"({well_depth})"
- )
-
- return None
+ return f"Row {row_index}: {error}"
def _create_records(
@@ -395,7 +385,7 @@ def _create_records(
)
field_activity = FieldActivity(
field_event=field_event,
- activity_type="groundwater level",
+ activity_type=GROUNDWATER_LEVEL_ACTIVITY_TYPE,
# Measuring staff now lives on structured participants and the
# sample participant link, not in field_activity.notes.
notes=None,
@@ -433,10 +423,10 @@ def _create_records(
if row.mp_height_differs_from_history:
errors.append(
- "Row "
- f"{row.row_index}: CSV mp_height ({row.mp_height}) differs "
- "from existing measuring point height "
- f"({row.existing_mp_height}); CSV value will be used"
+ f"Row {row.row_index}: "
+ + measuring_point_height_conflict_message(
+ row.mp_height, row.existing_mp_height
+ )
)
created.append(
@@ -463,7 +453,7 @@ def _create_records(
def _build_sample_name(row: _ValidatedRow) -> str:
"""Build the deterministic sample identifier used for create/update matching."""
- return f"{row.well.name}-WL-{row.measurement_dt.strftime('%Y%m%d%H%M')}"
+ return water_level_sample_name(row.well.name, row.measurement_dt)
def _find_existing_imported_sample(
@@ -482,7 +472,7 @@ def _find_existing_imported_sample(
.where(
Thing.name == row.well.name,
Thing.thing_type == "water well",
- FieldActivity.activity_type == "groundwater level",
+ FieldActivity.activity_type == GROUNDWATER_LEVEL_ACTIVITY_TYPE,
Sample.sample_name == sample_name,
)
.order_by(Sample.id.asc())
@@ -537,25 +527,19 @@ def _ensure_field_event_participants(
def _get_or_create_field_staff_contact(session: Session, staff_name: str) -> Contact:
"""Resolve or create the contact record used by field event participants."""
- contact_type = "Field Event Participant"
- organization = "NMBGMR"
# Contact uniqueness is enforced on (name, organization), so the lookup
# must use the same key to avoid missing an existing row with a different
# contact_type and attempting a duplicate insert.
contact = session.scalars(
select(Contact)
.where(Contact.name == staff_name)
- .where(Contact.organization == organization)
+ .where(Contact.organization == FIELD_STAFF_ORGANIZATION)
).first()
if contact is None:
- payload = {
- "name": staff_name,
- "role": "Technician",
- "organization": organization,
- "contact_type": contact_type,
- }
- contact = add_contact(session, payload, None, commit=False)
+ contact = add_contact(
+ session, field_staff_contact_payload(staff_name), None, commit=False
+ )
return contact
@@ -592,9 +576,9 @@ def _apply_sample_values(sample: Sample, row: _ValidatedRow, sample_name: str) -
"""Apply normalized sample values from the validated CSV row."""
sample.sample_date = row.measurement_dt
sample.sample_name = sample_name
- sample.sample_matrix = "groundwater"
+ sample.sample_matrix = SAMPLE_MATRIX
sample.sample_method = row.sample_method_term
- sample.qc_type = "Normal"
+ sample.qc_type = SAMPLE_QC_TYPE
sample.notes = row.water_level_notes
@@ -605,7 +589,7 @@ def _apply_observation_values(
observation.observation_datetime = row.measurement_dt
observation.parameter_id = parameter_id
observation.value = row.depth_to_water_ft
- observation.unit = "ft"
+ observation.unit = MEASUREMENT_UNIT
observation.measuring_point_height = row.resolved_mp_height
observation.groundwater_level_reason = row.level_status
observation.nma_data_quality = row.data_quality
diff --git a/services/well_inventory_csv.py b/services/well_inventory_csv.py
index 18e9a4f53..ccb2863b5 100644
--- a/services/well_inventory_csv.py
+++ b/services/well_inventory_csv.py
@@ -29,7 +29,7 @@
from sqlalchemy.orm import Session
from starlette.status import HTTP_400_BAD_REQUEST
-from core.constants import SRID_UTM_ZONE_13N, SRID_UTM_ZONE_12N, SRID_WGS84
+from core.constants import SRID_WGS84
from db import (
Group,
Location,
@@ -46,53 +46,40 @@
Parameter,
)
from db.engine import session_ctx
+from domain.field_staff import (
+ FIELD_STAFF_ORGANIZATION,
+ LEAD_ROLE,
+ PARTICIPANT_ROLE,
+ field_staff_contact_payload,
+)
+from domain.samples import water_level_sample_name
+from domain.values import build_notes, enum_value
+from domain.water_levels import (
+ GROUNDWATER_LEVEL_ACTIVITY_TYPE,
+ MEASUREMENT_UNIT,
+ SAMPLE_MATRIX,
+)
+from domain.wells import (
+ alternate_ids,
+ autogen_prefix,
+ elevation_m_from_ft,
+ historic_depth_to_water_note,
+ release_status,
+ resolve_measuring_point_height,
+ srid_for_utm_zone,
+ well_purposes,
+)
from pydantic import ValidationError
from schemas.thing import CreateWell
from schemas.well_inventory import WellInventoryRow
from services.contact_helper import add_contact
from services.exceptions_helper import PydanticStyleException
from services.thing_helper import add_thing, find_water_wells_by_name
-from services.util import transform_srid, convert_ft_to_m
+from services.util import transform_srid
-AUTOGEN_DEFAULT_PREFIX = "NM-"
-AUTOGEN_PREFIX_REGEX = re.compile(r"^[A-Z]{2,3}-$", re.IGNORECASE)
-AUTOGEN_TOKEN_REGEX = re.compile(
- r"^(?P[A-Z]{2,3})\s*-\s*(?:x{4}|X{4})$", re.IGNORECASE
-)
PROGRESS_INTERVAL = 25
-def _extract_autogen_prefix(well_id: str | None) -> str | None:
- """
- Return normalized auto-generation prefix when a placeholder token is provided.
-
- Supported forms:
- - ``XY-`` (existing behavior)
- - ``WL-XXXX`` / ``SAC-XXXX`` / ``ABC-XXXX`` (2-3 uppercase letter prefixes)
- - blank value (uses default ``NM-`` prefix)
- """
- # Normalize input
- value = (well_id or "").strip()
-
- # Blank / missing value -> use default prefix
- if not value:
- return AUTOGEN_DEFAULT_PREFIX
-
- # Direct prefix form, e.g. "XY-" or "ABC-"
- if AUTOGEN_PREFIX_REGEX.match(value):
- # Ensure normalized trailing dash and uppercase
- prefix = value[:-1].upper()
- return f"{prefix}-"
-
- # Token form, e.g. "WL-XXXX", "SAC-xxxx", with optional spaces around "-"
- m = AUTOGEN_TOKEN_REGEX.match(value)
- if m:
- prefix = m.group("prefix").upper()
- return f"{prefix}-"
-
- return None
-
-
def import_well_inventory_csv(*args, **kw) -> dict:
with session_ctx() as session:
return _import_well_inventory_csv(session, *args, **kw)
@@ -363,47 +350,28 @@ def _extract_field_from_value_error(error_text: str) -> str:
def _make_location(model) -> Location:
point = Point(model.utm_easting, model.utm_northing)
- # TODO: this needs to be more sophisticated in the future. Likely more than 13N and 12N will be used
- if model.utm_zone == "13N":
- source_srid = SRID_UTM_ZONE_13N
- elif model.utm_zone == "12N":
- source_srid = SRID_UTM_ZONE_12N
- else:
- raise ValueError(f"Unsupported UTM zone: {model.utm_zone}")
-
# Convert the point to a WGS84 coordinate system
transformed_point = transform_srid(
- point, source_srid=source_srid, target_srid=SRID_WGS84
+ point,
+ source_srid=srid_for_utm_zone(model.utm_zone),
+ target_srid=SRID_WGS84,
)
- elevation_ft = model.elevation_ft
- elevation_m = (
- convert_ft_to_m(float(elevation_ft)) if elevation_ft is not None else 0.0
- )
-
- release_status = "draft"
- if model.public_availability_acknowledgement is True:
- release_status = "public"
- elif model.public_availability_acknowledgement is False:
- release_status = "private"
- loc = Location(
+ return Location(
point=transformed_point.wkt,
- elevation=elevation_m,
- release_status=release_status,
+ elevation=elevation_m_from_ft(model.elevation_ft),
+ release_status=release_status(model.public_availability_acknowledgement),
)
- return loc
-
def _make_contact(model: WellInventoryRow, well: Thing, idx) -> dict:
# add contact
- notes = []
- for content, note_type in (
- (model.result_communication_preference, "Communication"),
- (model.contact_special_requests_notes, "General"),
- ):
- if content is not None:
- notes.append({"content": content, "note_type": note_type})
+ notes = build_notes(
+ (
+ (model.result_communication_preference, "Communication"),
+ (model.contact_special_requests_notes, "General"),
+ )
+ )
emails = []
phones = []
@@ -517,9 +485,8 @@ def _find_existing_imported_well(
session: Session, model: WellInventoryRow
) -> Thing | None:
if model.measurement_date_time is not None:
- sample_name = (
- f"{model.well_name_point_id}-WL-"
- f"{model.measurement_date_time.strftime('%Y%m%d%H%M')}"
+ sample_name = water_level_sample_name(
+ model.well_name_point_id, model.measurement_date_time
)
existing = session.scalars(
select(Thing)
@@ -529,7 +496,7 @@ def _find_existing_imported_well(
.where(
Thing.name == model.well_name_point_id,
Thing.thing_type == "water well",
- FieldActivity.activity_type == "groundwater level",
+ FieldActivity.activity_type == GROUNDWATER_LEVEL_ACTIVITY_TYPE,
Sample.sample_name == sample_name,
)
.order_by(Thing.id.asc())
@@ -567,13 +534,11 @@ def _make_row_models(rows, session, progress_callback=None):
raise ValueError("Field required")
well_id = row.get("well_name_point_id")
- autogen_prefix = _extract_autogen_prefix(well_id)
- if autogen_prefix is not None:
- offset = offsets.get(autogen_prefix, 0)
- well_id, offset = _generate_autogen_well_id(
- session, autogen_prefix, offset
- )
- offsets[autogen_prefix] = offset
+ prefix = autogen_prefix(well_id)
+ if prefix is not None:
+ offset = offsets.get(prefix, 0)
+ well_id, offset = _generate_autogen_well_id(session, prefix, offset)
+ offsets[prefix] = offset
row["well_name_point_id"] = well_id
elif not well_id:
raise ValueError("Field required")
@@ -644,18 +609,19 @@ def _make_row_models(rows, session, progress_callback=None):
def _add_field_staff(
session: Session, fs: str, field_event: FieldEvent, role: str, user: str
) -> None:
- ct = "Field Event Participant"
- org = "NMBGMR"
+ # Contact uniqueness is enforced on (name, organization), so the lookup must
+ # use the same key. Adding contact_type here misses an existing row created
+ # with a different type and then fails on the duplicate insert.
contact = session.scalars(
select(Contact)
.where(Contact.name == fs)
- .where(Contact.organization == org)
- .where(Contact.contact_type == ct)
+ .where(Contact.organization == FIELD_STAFF_ORGANIZATION)
).first()
if not contact:
- payload = dict(name=fs, role="Technician", organization=org, contact_type=ct)
- contact = add_contact(session, payload, user, commit=False)
+ contact = add_contact(
+ session, field_staff_contact_payload(fs), user, commit=False
+ )
fec = FieldEventParticipant(
field_event=field_event, contact_id=contact.id, participant_role=role
@@ -694,16 +660,11 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user)
session.add(directions_note)
# add data provenance records
- elevation_method = (
- model.elevation_method.value
- if hasattr(model.elevation_method, "value")
- else (model.elevation_method or "Unknown")
- )
dp = DataProvenance(
target_id=loc.id,
target_table="location",
field_name="elevation",
- collection_method=elevation_method,
+ collection_method=enum_value(model.elevation_method, "Unknown"),
)
session.add(dp)
@@ -712,67 +673,29 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user)
# --------------------
# add Thing
- """
- Developer's note
-
- Laila said that the depth source is almost always the source for the historic depth to water.
- She indicated that it would be acceptable to use the depth source for the historic depth to water source.
- """
- if model.depth_source:
- historic_depth_to_water_source = (
- model.depth_source.value
- if hasattr(model.depth_source, "value")
- else model.depth_source
- ).lower()
- else:
- historic_depth_to_water_source = "unknown"
+ historic_depth_note = historic_depth_to_water_note(
+ model.historic_depth_to_water_ft, model.depth_source
+ )
- if model.historic_depth_to_water_ft is not None:
- historic_depth_note = f"historic depth to water: {model.historic_depth_to_water_ft} ft - source: {historic_depth_to_water_source}"
- else:
- historic_depth_note = None
-
- well_notes = []
- for note_content, note_type in (
- (model.specific_location_of_well, "Access"),
- (model.contact_special_requests_notes, "General"),
- (model.well_measuring_notes, "Sampling Procedure"),
- (model.sampling_scenario_notes, "Sampling Procedure"),
- (model.well_notes, "General"),
- (model.water_notes, "Water"),
- (historic_depth_note, "Historical"),
+ well_notes = build_notes(
(
+ (model.specific_location_of_well, "Access"),
+ (model.contact_special_requests_notes, "General"),
+ (model.well_measuring_notes, "Sampling Procedure"),
+ (model.sampling_scenario_notes, "Sampling Procedure"),
+ (model.well_notes, "General"),
+ (model.water_notes, "Water"),
+ (historic_depth_note, "Historical"),
(
- f"Sample possible: {model.sample_possible}"
- if model.sample_possible is not None
- else None
+ (
+ f"Sample possible: {model.sample_possible}"
+ if model.sample_possible is not None
+ else None
+ ),
+ "Sampling Procedure",
),
- "Sampling Procedure",
- ),
- ):
- if note_content is not None:
- well_notes.append({"content": note_content, "note_type": note_type})
-
- alternate_ids = []
- for alternate_id, alternate_organization in (
- (model.site_name, "NMBGMR"),
- (model.ose_well_record_id, "NMOSE"),
- ):
- if alternate_id is not None:
- alternate_ids.append(
- {
- "thing_id": -1,
- "alternate_id": alternate_id,
- "alternate_organization": alternate_organization,
- "relation": "same_as",
- }
- )
-
- well_purposes = []
- if model.well_purpose:
- well_purposes.append(model.well_purpose)
- if model.well_purpose_2:
- well_purposes.append(model.well_purpose_2)
+ )
+ )
monitoring_frequencies = []
if model.monitoring_frequency:
@@ -783,21 +706,9 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user)
}
)
- if (
- model.mp_height is not None
- and model.measuring_point_height_ft is not None
- and model.mp_height != model.measuring_point_height_ft
- ):
- raise ValueError(
- "Conflicting values for measuring point height: mp_height and measuring_point_height_ft"
- )
-
- if model.measuring_point_height_ft is not None:
- universal_mp_height = model.measuring_point_height_ft
- elif model.mp_height is not None:
- universal_mp_height = model.mp_height
- else:
- universal_mp_height = None
+ universal_mp_height = resolve_measuring_point_height(
+ model.mp_height, model.measuring_point_height_ft
+ )
data = CreateWell(
location_id=loc.id,
@@ -815,20 +726,12 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user)
well_pump_depth=model.well_pump_depth_ft,
is_suitable_for_datalogger=model.datalogger_possible,
is_open=model.is_open,
- well_status=(
- model.well_status.value
- if hasattr(model.well_status, "value")
- else model.well_status
- ),
- monitoring_status=(
- model.monitoring_status.value
- if hasattr(model.monitoring_status, "value")
- else model.monitoring_status
- ),
+ well_status=enum_value(model.well_status),
+ monitoring_status=enum_value(model.monitoring_status),
notes=well_notes,
- well_purposes=well_purposes,
+ well_purposes=well_purposes(model.well_purpose, model.well_purpose_2),
monitoring_frequencies=monitoring_frequencies,
- alternate_ids=alternate_ids,
+ alternate_ids=alternate_ids(model.site_name, model.ose_well_record_id),
)
well_data = data.model_dump()
@@ -878,9 +781,9 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user)
# add field staff
for fsi, role in (
- (model.field_staff, "Lead"),
- (model.field_staff_2, "Participant"),
- (model.field_staff_3, "Participant"),
+ (model.field_staff, LEAD_ROLE),
+ (model.field_staff_2, PARTICIPANT_ROLE),
+ (model.field_staff_3, PARTICIPANT_ROLE),
):
if not fsi:
continue
@@ -920,24 +823,19 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user)
# create FieldActivity
gwl_field_activity = FieldActivity(
field_event=fe,
- activity_type="groundwater level",
+ activity_type=GROUNDWATER_LEVEL_ACTIVITY_TYPE,
notes="Groundwater level measurement activity conducted during well inventory field event.",
)
session.add(gwl_field_activity)
session.flush()
# create Sample
- sample_method = (
- model.sample_method.value
- if hasattr(model.sample_method, "value")
- else (model.sample_method or "Unknown")
- )
sample = Sample(
field_activity_id=gwl_field_activity.id,
sample_date=model.measurement_date_time,
- sample_name=f"{well.name}-WL-{model.measurement_date_time.strftime('%Y%m%d%H%M')}",
- sample_matrix="groundwater",
- sample_method=sample_method,
+ sample_name=water_level_sample_name(well.name, model.measurement_date_time),
+ sample_matrix=SAMPLE_MATRIX,
+ sample_method=enum_value(model.sample_method, "Unknown"),
notes=model.water_level_notes,
)
session.add(sample)
@@ -949,7 +847,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user)
sample_id=sample.id,
parameter_id=parameter.id,
value=model.depth_to_water_ft,
- unit="ft",
+ unit=MEASUREMENT_UNIT,
observation_datetime=model.measurement_date_time,
measuring_point_height=universal_mp_height,
groundwater_level_reason=(
diff --git a/tests/test_domain_values.py b/tests/test_domain_values.py
new file mode 100644
index 000000000..a638b777b
--- /dev/null
+++ b/tests/test_domain_values.py
@@ -0,0 +1,109 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Shared value helpers and field staff rules. No database, no fixtures."""
+
+from enum import Enum
+
+from domain.field_staff import (
+ FIELD_STAFF_CONTACT_TYPE,
+ FIELD_STAFF_ORGANIZATION,
+ field_staff_contact_payload,
+ field_staff_entries,
+)
+from domain.values import build_notes, enum_value
+
+
+class _Method(Enum):
+ STEEL_TAPE = "Steel Tape"
+
+
+# --------------------------------------------------------------------------
+# enum_value
+# --------------------------------------------------------------------------
+def test_enum_value_unwraps_an_enum():
+ assert enum_value(_Method.STEEL_TAPE) == "Steel Tape"
+
+
+def test_enum_value_passes_a_plain_string_through():
+ assert enum_value("Steel Tape") == "Steel Tape"
+
+
+def test_enum_value_without_a_default_returns_falsy_values_unchanged():
+ assert enum_value(None) is None
+ assert enum_value("") == ""
+
+
+def test_enum_value_substitutes_the_default_for_falsy_values():
+ assert enum_value(None, "Unknown") == "Unknown"
+ assert enum_value("", "Unknown") == "Unknown"
+
+
+def test_enum_value_default_does_not_override_an_enum():
+ assert enum_value(_Method.STEEL_TAPE, "Unknown") == "Steel Tape"
+
+
+# --------------------------------------------------------------------------
+# build_notes
+# --------------------------------------------------------------------------
+def test_build_notes_keeps_order_and_drops_missing_content():
+ assert build_notes(
+ (
+ ("locked gate", "Access"),
+ (None, "General"),
+ ("call ahead", "Communication"),
+ )
+ ) == [
+ {"content": "locked gate", "note_type": "Access"},
+ {"content": "call ahead", "note_type": "Communication"},
+ ]
+
+
+def test_build_notes_keeps_an_empty_string():
+ # Only None means "no note"; the importers never filtered on truthiness.
+ assert build_notes((("", "General"),)) == [{"content": "", "note_type": "General"}]
+
+
+def test_build_notes_of_nothing_is_empty():
+ assert build_notes(()) == []
+
+
+# --------------------------------------------------------------------------
+# field staff
+# --------------------------------------------------------------------------
+def test_field_staff_entries_assigns_lead_then_participants():
+ assert field_staff_entries("A Lopez", "B Chen", "C Diaz") == (
+ ("A Lopez", "Lead"),
+ ("B Chen", "Participant"),
+ ("C Diaz", "Participant"),
+ )
+
+
+def test_field_staff_entries_drops_blank_columns():
+ assert field_staff_entries("A Lopez", None, "") == (("A Lopez", "Lead"),)
+ assert field_staff_entries(None, "B Chen", None) == (("B Chen", "Participant"),)
+ assert field_staff_entries(None, None, None) == ()
+
+
+def test_field_staff_contact_payload_uses_the_shared_defaults():
+ assert field_staff_contact_payload("A Lopez") == {
+ "name": "A Lopez",
+ "role": "Technician",
+ "organization": FIELD_STAFF_ORGANIZATION,
+ "contact_type": FIELD_STAFF_CONTACT_TYPE,
+ }
+
+
+# ============= EOF =============================================
diff --git a/tests/test_domain_water_levels.py b/tests/test_domain_water_levels.py
new file mode 100644
index 000000000..64ea0ed74
--- /dev/null
+++ b/tests/test_domain_water_levels.py
@@ -0,0 +1,138 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Water level and sample-naming rules. No database, no fixtures."""
+
+from datetime import datetime
+from decimal import Decimal
+
+from domain.samples import water_level_sample_name
+from domain.water_levels import (
+ depth_to_water_error,
+ measuring_point_height_conflict_message,
+ reconcile_measuring_point_height,
+)
+
+
+# --------------------------------------------------------------------------
+# reconcile_measuring_point_height
+# --------------------------------------------------------------------------
+def test_reconcile_prefers_the_csv_height_and_reports_the_difference():
+ resolved, existing, differs = reconcile_measuring_point_height(4.0, 3.5)
+
+ assert resolved == 4.0
+ assert existing == 3.5
+ assert differs is True
+
+
+def test_reconcile_falls_back_to_the_recorded_height():
+ resolved, existing, differs = reconcile_measuring_point_height(None, 3.5)
+
+ assert resolved == 3.5
+ assert existing == 3.5
+ assert differs is False
+
+
+def test_reconcile_coerces_a_decimal_history_value():
+ resolved, existing, differs = reconcile_measuring_point_height(None, Decimal("3.5"))
+
+ assert resolved == 3.5
+ assert isinstance(existing, float)
+ assert differs is False
+
+
+def test_reconcile_allows_both_missing():
+ assert reconcile_measuring_point_height(None, None) == (None, None, False)
+
+
+def test_reconcile_does_not_flag_a_matching_height():
+ _, _, differs = reconcile_measuring_point_height(3.5, Decimal("3.5"))
+
+ assert differs is False
+
+
+def test_reconcile_does_not_flag_a_csv_height_with_no_history():
+ resolved, existing, differs = reconcile_measuring_point_height(4.0, None)
+
+ assert resolved == 4.0
+ assert existing is None
+ assert differs is False
+
+
+def test_measuring_point_height_conflict_message_names_both_values():
+ assert measuring_point_height_conflict_message(1.5, 2.0) == (
+ "CSV mp_height (1.5) differs from existing measuring point height (2.0); "
+ "CSV value will be used"
+ )
+
+
+# --------------------------------------------------------------------------
+# depth_to_water_error
+# --------------------------------------------------------------------------
+def test_depth_to_water_error_rejects_a_reading_below_the_well_bottom():
+ assert depth_to_water_error(12.5, 1.0, 10.0) == (
+ "depth_to_water_ft minus measuring point height (11.5) "
+ "must be less than well depth (10.0)"
+ )
+
+
+def test_depth_to_water_error_accepts_a_reading_inside_the_well():
+ assert depth_to_water_error(8.0, 1.0, 10.0) is None
+
+
+def test_depth_to_water_error_rejects_water_exactly_at_the_bottom():
+ # The corrected depth must be strictly less than the well depth.
+ assert depth_to_water_error(11.0, 1.0, 10.0) is not None
+
+
+def test_depth_to_water_error_subtracts_the_measuring_point_height():
+ # Without the correction this reading would look like it was past the bottom.
+ assert depth_to_water_error(10.5, 1.0, 10.0) is None
+
+
+def test_depth_to_water_error_coerces_a_decimal_well_depth():
+ assert depth_to_water_error(12.5, 1.0, Decimal("10.0")) == (
+ "depth_to_water_ft minus measuring point height (11.5) "
+ "must be less than well depth (10.0)"
+ )
+
+
+def test_depth_to_water_error_skips_when_an_input_is_missing():
+ assert depth_to_water_error(None, 1.0, 10.0) is None
+ assert depth_to_water_error(12.5, None, 10.0) is None
+ assert depth_to_water_error(12.5, 1.0, None) is None
+
+
+# --------------------------------------------------------------------------
+# water_level_sample_name
+# --------------------------------------------------------------------------
+def test_water_level_sample_name_is_deterministic():
+ measured_at = datetime(2026, 3, 4, 9, 5)
+
+ assert water_level_sample_name("AR0001", measured_at) == "AR0001-WL-202603040905"
+
+
+def test_water_level_sample_name_ignores_sub_minute_precision():
+ # Both importers must agree on the name for re-import matching to work, so
+ # seconds are deliberately not part of it.
+ with_seconds = datetime(2026, 3, 4, 9, 5, 42)
+ without_seconds = datetime(2026, 3, 4, 9, 5)
+
+ assert water_level_sample_name("AR0001", with_seconds) == water_level_sample_name(
+ "AR0001", without_seconds
+ )
+
+
+# ============= EOF =============================================
diff --git a/tests/test_domain_wells.py b/tests/test_domain_wells.py
new file mode 100644
index 000000000..5429f10d8
--- /dev/null
+++ b/tests/test_domain_wells.py
@@ -0,0 +1,210 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Well rules. No database, no fixtures."""
+
+from enum import Enum
+
+import pytest
+
+from core.constants import SRID_UTM_ZONE_12N, SRID_UTM_ZONE_13N
+from domain.wells import (
+ AUTOGEN_DEFAULT_PREFIX,
+ ConflictingMeasuringPointHeight,
+ UnsupportedUtmZone,
+ alternate_ids,
+ autogen_prefix,
+ elevation_m_from_ft,
+ historic_depth_to_water_note,
+ historic_depth_to_water_source,
+ release_status,
+ resolve_measuring_point_height,
+ srid_for_utm_zone,
+ well_purposes,
+)
+
+
+class _DepthSource(Enum):
+ DRILLER = "Driller"
+
+
+# --------------------------------------------------------------------------
+# autogen_prefix
+# --------------------------------------------------------------------------
+@pytest.mark.parametrize(
+ "well_id, expected",
+ [
+ ("", AUTOGEN_DEFAULT_PREFIX),
+ (" ", AUTOGEN_DEFAULT_PREFIX),
+ (None, AUTOGEN_DEFAULT_PREFIX),
+ ("XY-", "XY-"),
+ ("xy-", "XY-"),
+ ("ABC-", "ABC-"),
+ ("WL-XXXX", "WL-"),
+ ("SAC-xxxx", "SAC-"),
+ ("WL - XXXX", "WL-"),
+ (" WL-XXXX ", "WL-"),
+ ],
+)
+def test_autogen_prefix_recognizes_placeholders(well_id, expected):
+ assert autogen_prefix(well_id) == expected
+
+
+@pytest.mark.parametrize(
+ "well_id",
+ ["AR0001", "WL-0001", "A-", "ABCD-", "WL-XXX", "WL-XXXXX", "NM-1234"],
+)
+def test_autogen_prefix_leaves_real_ids_alone(well_id):
+ assert autogen_prefix(well_id) is None
+
+
+# --------------------------------------------------------------------------
+# srid_for_utm_zone
+# --------------------------------------------------------------------------
+def test_srid_for_utm_zone_maps_supported_zones():
+ assert srid_for_utm_zone("13N") == SRID_UTM_ZONE_13N
+ assert srid_for_utm_zone("12N") == SRID_UTM_ZONE_12N
+
+
+@pytest.mark.parametrize("zone", ["11N", "13n", "", None])
+def test_srid_for_utm_zone_rejects_unsupported_zones(zone):
+ with pytest.raises(UnsupportedUtmZone, match=f"Unsupported UTM zone: {zone}"):
+ srid_for_utm_zone(zone)
+
+
+def test_unsupported_utm_zone_is_a_value_error():
+ # The importer catches ValueError to fail a single row rather than the run.
+ assert issubclass(UnsupportedUtmZone, ValueError)
+
+
+# --------------------------------------------------------------------------
+# elevation_m_from_ft
+# --------------------------------------------------------------------------
+def test_elevation_m_from_ft_converts():
+ assert elevation_m_from_ft(10) == 3.048
+ assert elevation_m_from_ft("10") == 3.048
+
+
+def test_elevation_m_from_ft_defaults_missing_to_zero():
+ # Location.elevation is not nullable and the sheet leaves it blank.
+ assert elevation_m_from_ft(None) == 0.0
+
+
+# --------------------------------------------------------------------------
+# release_status
+# --------------------------------------------------------------------------
+def test_release_status_is_three_state():
+ assert release_status(True) == "public"
+ assert release_status(False) == "private"
+ assert release_status(None) == "draft"
+
+
+# --------------------------------------------------------------------------
+# resolve_measuring_point_height
+# --------------------------------------------------------------------------
+def test_resolve_measuring_point_height_prefers_the_explicit_ft_column():
+ assert resolve_measuring_point_height(None, 2.5) == 2.5
+ assert resolve_measuring_point_height(2.5, 2.5) == 2.5
+
+
+def test_resolve_measuring_point_height_falls_back_to_mp_height():
+ assert resolve_measuring_point_height(1.5, None) == 1.5
+
+
+def test_resolve_measuring_point_height_allows_both_missing():
+ assert resolve_measuring_point_height(None, None) is None
+
+
+def test_resolve_measuring_point_height_rejects_disagreement():
+ with pytest.raises(ConflictingMeasuringPointHeight) as exc:
+ resolve_measuring_point_height(1.5, 2.5)
+
+ assert str(exc.value) == (
+ "Conflicting values for measuring point height: "
+ "mp_height and measuring_point_height_ft"
+ )
+
+
+def test_conflicting_measuring_point_height_is_a_value_error():
+ assert issubclass(ConflictingMeasuringPointHeight, ValueError)
+
+
+def test_resolve_measuring_point_height_accepts_a_shared_zero():
+ # 0.0 is a real height, not a missing one.
+ assert resolve_measuring_point_height(0.0, 0.0) == 0.0
+
+
+# --------------------------------------------------------------------------
+# historic depth to water
+# --------------------------------------------------------------------------
+def test_historic_depth_to_water_source_lowercases_an_enum():
+ assert historic_depth_to_water_source(_DepthSource.DRILLER) == "driller"
+
+
+def test_historic_depth_to_water_source_lowercases_a_string():
+ assert historic_depth_to_water_source("Driller") == "driller"
+
+
+@pytest.mark.parametrize("depth_source", [None, ""])
+def test_historic_depth_to_water_source_defaults_to_unknown(depth_source):
+ assert historic_depth_to_water_source(depth_source) == "unknown"
+
+
+def test_historic_depth_to_water_note_renders_value_and_source():
+ assert (
+ historic_depth_to_water_note(42.5, _DepthSource.DRILLER)
+ == "historic depth to water: 42.5 ft - source: driller"
+ )
+
+
+def test_historic_depth_to_water_note_is_none_without_a_reading():
+ assert historic_depth_to_water_note(None, _DepthSource.DRILLER) is None
+
+
+# --------------------------------------------------------------------------
+# well_purposes / alternate_ids
+# --------------------------------------------------------------------------
+def test_well_purposes_drops_blanks_and_keeps_order():
+ assert well_purposes("Monitoring", "Domestic") == ["Monitoring", "Domestic"]
+ assert well_purposes("Monitoring", None) == ["Monitoring"]
+ assert well_purposes(None, "Domestic") == ["Domestic"]
+ assert well_purposes(None, None) == []
+
+
+def test_alternate_ids_credits_the_right_organization():
+ assert alternate_ids("SITE-1", "OSE-9") == [
+ {
+ "thing_id": -1,
+ "alternate_id": "SITE-1",
+ "alternate_organization": "NMBGMR",
+ "relation": "same_as",
+ },
+ {
+ "thing_id": -1,
+ "alternate_id": "OSE-9",
+ "alternate_organization": "NMOSE",
+ "relation": "same_as",
+ },
+ ]
+
+
+def test_alternate_ids_skips_missing_identifiers():
+ assert alternate_ids(None, None) == []
+ assert [
+ entry["alternate_organization"] for entry in alternate_ids(None, "OSE-9")
+ ] == ["NMOSE"]
+
+
+# ============= EOF =============================================
diff --git a/tests/test_well_inventory.py b/tests/test_well_inventory.py
index 23686fd79..aa16afddb 100644
--- a/tests/test_well_inventory.py
+++ b/tests/test_well_inventory.py
@@ -1281,29 +1281,31 @@ def test_generate_autogen_well_id_with_offset(self):
def test_extract_autogen_prefix_pattern(self):
"""Test auto-generation prefix extraction for supported placeholders."""
- from services.well_inventory_csv import _extract_autogen_prefix
+ # The rule itself now lives in domain/wells.py; see
+ # tests/test_domain_wells.py for the database-free version of this.
+ from domain.wells import autogen_prefix
# Existing supported form
- assert _extract_autogen_prefix("XY-") == "XY-"
- assert _extract_autogen_prefix("AB-") == "AB-"
+ assert autogen_prefix("XY-") == "XY-"
+ assert autogen_prefix("AB-") == "AB-"
# Placeholder tokens are accepted case-insensitively and normalized.
- assert _extract_autogen_prefix("WL-XXXX") == "WL-"
- assert _extract_autogen_prefix("SAC-XXXX") == "SAC-"
- assert _extract_autogen_prefix("ABC -xxxx") == "ABC-"
- assert _extract_autogen_prefix("wl-xxxx") == "WL-"
- assert _extract_autogen_prefix("abc - XXXX") == "ABC-"
+ assert autogen_prefix("WL-XXXX") == "WL-"
+ assert autogen_prefix("SAC-XXXX") == "SAC-"
+ assert autogen_prefix("ABC -xxxx") == "ABC-"
+ assert autogen_prefix("wl-xxxx") == "WL-"
+ assert autogen_prefix("abc - XXXX") == "ABC-"
# Blank values use default prefix
- assert _extract_autogen_prefix("") == "NM-"
- assert _extract_autogen_prefix(" ") == "NM-"
+ assert autogen_prefix("") == "NM-"
+ assert autogen_prefix(" ") == "NM-"
# Unsupported forms
- assert _extract_autogen_prefix("XY-001") is None
- assert _extract_autogen_prefix("XYZ-") == "XYZ-"
- assert _extract_autogen_prefix("X-") is None
- assert _extract_autogen_prefix("123-") is None
- assert _extract_autogen_prefix("USER-XXXX") is None
+ assert autogen_prefix("XY-001") is None
+ assert autogen_prefix("XYZ-") == "XYZ-"
+ assert autogen_prefix("X-") is None
+ assert autogen_prefix("123-") is None
+ assert autogen_prefix("USER-XXXX") is None
def test_make_row_models_missing_well_name_point_id_column_errors(self):
"""Missing well_name_point_id column should fail validation (blank cell is separate)."""
From 83e66040a2977e95a429360c0ba11b8ba6eb3327 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Fri, 7 Aug 2026 10:57:54 -0700
Subject: [PATCH 041/151] feat(geothermal): add /thing/geothermal-well
endpoints
The OcotilloUI temp-depth log and records grid call
GET /thing/geothermal-well, but no such route was registered, so FastAPI
matched /thing/{thing_id} (an int path param) and returned 422 for every
request.
Register the geothermal router ahead of thing_router so its explicit
/thing/geothermal-well paths win over /thing/{thing_id}, and implement
the list and by-WellDataID routes. Both read from the legacy NM_Wells
staging mirror via services/geothermal_helper.py until the NM_Wells ->
Ocotillo transform lands; the URL is stable across that swap.
Recovered from an unreferenced WIP commit (012ffb6e) on
feat/geothermal-data-ingestion and rebased onto staging.
Co-Authored-By: Claude Opus 5
---
api/geothermal.py | 190 +++++++++++----------------
core/initializers.py | 4 +
schemas/geothermal.py | 39 +++++-
services/geothermal_helper.py | 99 ++++++++++++++
transfers/seed_geothermal.py | 234 ++++++++++++++++++++++++++++++++++
5 files changed, 449 insertions(+), 117 deletions(-)
create mode 100644 services/geothermal_helper.py
create mode 100644 transfers/seed_geothermal.py
diff --git a/api/geothermal.py b/api/geothermal.py
index 7d6f96395..0e79368d9 100644
--- a/api/geothermal.py
+++ b/api/geothermal.py
@@ -13,126 +13,84 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
+"""Geothermal well endpoints.
+
+TEMPORARY BACKING: routes read from the legacy NM_Wells staging mirror
+(``db/nmw_legacy.py``) via ``services/geothermal_helper.py``. Once the
+NM_Wells -> Ocotillo transform lands these will be backed by the ``thing``
+table and ``thing_id`` will be populated on the response. The route path lives
+under ``/thing`` so the URL is stable across that swap.
+"""
+
+from typing import Optional
+from uuid import UUID
+
from fastapi import APIRouter
+from fastapi_pagination.ext.sqlalchemy import paginate
+from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND
-#
-# from db.geothermal import (
-# GeothermalTemperatureProfile,
-# GeothermalTemperatureProfileObservation,
-# GeothermalBottomHoleTemperature,
-# GeothermalWellInterval,
-# GeothermalHeatFlow,
-# GeothermalThermalConductivity,
-# GeothermalSampleSet,
-# GeothermalBottomHoleTemperatureHeader,
-# )
+from api.pagination import CustomPage
+from core.dependencies import session_dependency, viewer_dependency
+from schemas.geothermal import GeothermalWellResponse
+from services.exceptions_helper import PydanticStyleException
+from services.geothermal_helper import (
+ geothermal_wells_transformer,
+ get_geothermal_well_by_id,
+ get_geothermal_wells_query,
+)
-router = APIRouter(prefix="/geothermal", tags=["geothermal"])
+router = APIRouter(prefix="/thing", tags=["geothermal"])
-# @router.post("/sample_set", status_code=status.HTTP_201_CREATED)
-# async def add_geothermal_sample_set(
-# sample_set_data: CreateGeothermalSampleSet, # Replace with appropriate schema
-# session: session_dependency
-# ):
-# """
-# Add a new geothermal sample set.
-# """
-# # Assuming you have a model for GeothermalSampleSet
-# return adder(session, GeothermalSampleSet, sample_set_data)
-#
-#
-# @router.post("/bottom_hole_temperature_header", status_code=status.HTTP_201_CREATED)
-# async def add_bottom_hole_temperature_header(
-# bottom_hole_temperature_header_data: CreateBottomHoleTemperatureHeader,
-# session: session_dependency
-# ):
-# """
-# Add a new bottom hole temperature header.
-# """
-# # Assuming you have a model for GeothermalBottomHoleTemperatureHeader
-# return adder(
-# session,
-# GeothermalBottomHoleTemperatureHeader,
-# bottom_hole_temperature_header_data,
-# )
-#
-#
-# @router.post("/temperature_profile", status_code=status.HTTP_201_CREATED)
-# async def add_temperature_profile(
-# temperature_profile_data: CreateTemperatureProfile,
-# session: session_dependency
-# ):
-# """
-# Add a new temperature profile.
-# """
-# return adder(session, GeothermalTemperatureProfile, temperature_profile_data)
-#
-#
-# @router.post("/temperature_profile_observation", status_code=status.HTTP_201_CREATED)
-# async def add_temperature_profile_observation(
-# temperature_profile_observation_data: CreateTemperatureProfileObservation,
-# session: session_dependency
-# ):
-# """
-# Add a new temperature profile observation.
-# """
-# return adder(
-# session,
-# GeothermalTemperatureProfileObservation,
-# temperature_profile_observation_data,
-# )
-#
-#
-# @router.post("/bottom_hole_temperature", status_code=status.HTTP_201_CREATED)
-# async def add_bottom_hole_temperature(
-# bottom_hole_temperature_data: CreateBottomHoleTemperature,
-# session: session_dependency
-# ):
-# """
-# Add a new bottom hole temperature.
-# """
-# return adder(
-# session,
-# GeothermalBottomHoleTemperature, # Assuming this is the correct model
-# bottom_hole_temperature_data,
-# )
-#
-#
-# @router.post("/interval", status_code=status.HTTP_201_CREATED)
-# async def add_geothermal_interval(
-# interval_data: CreateGeothermalInterval, # Replace with appropriate schema
-# session: session_dependency
-# ):
-# """
-# Add a new geothermal interval.
-# """
-# # Assuming you have a model for GeothermalInterval
-# return adder(session, GeothermalWellInterval, interval_data)
-#
-#
-# @router.post("/thermal_conductivity", status_code=status.HTTP_201_CREATED)
-# async def add_thermal_conductivity(
-# thermal_conductivity_data: CreateThermalConductivity, # Replace with appropriate schema
-# session: session_dependency
-# ):
-# """
-# Add a new geothermal thermal conductivity.
-# """
-# # Assuming you have a model for GeothermalThermalConductivity
-# return adder(session, GeothermalThermalConductivity, thermal_conductivity_data)
-#
-#
-# @router.post("/heat_flow", status_code=status.HTTP_201_CREATED)
-# async def add_heat_flow(
-# heat_flow_data: CreateHeatFlow,
-# session: session_dependency
-# ):
-# """
-# Add a new geothermal heat flow.
-# """
-# # Assuming you have a model for GeothermalHeatFlow
-# return adder(session, GeothermalHeatFlow, heat_flow_data)
-#
+@router.get(
+ "/geothermal-well",
+ summary="Get all geothermal wells",
+ status_code=HTTP_200_OK,
+)
+def get_geothermal_wells(
+ user: viewer_dependency,
+ session: session_dependency,
+ county: Optional[str] = None,
+ name_contains: Optional[str] = None,
+) -> CustomPage[GeothermalWellResponse]:
+ """List geothermal wells.
+
+ NOTE: sourced from the legacy NM_Wells mirror (NMW_WellHeaders where
+ GthrmExist is set). Will be re-pointed at the thing table post-transform.
+ """
+ sql = get_geothermal_wells_query(county=county, name_contains=name_contains)
+ return paginate(query=sql, conn=session, transformer=geothermal_wells_transformer)
+
+
+@router.get(
+ "/geothermal-well/{well_data_id}",
+ summary="Get geothermal well by legacy WellDataID",
+ status_code=HTTP_200_OK,
+)
+def get_geothermal_well(
+ user: viewer_dependency,
+ well_data_id: UUID,
+ session: session_dependency,
+) -> GeothermalWellResponse:
+ """Get a single geothermal well by its legacy NMW WellDataID (GUID).
+
+ NOTE: keyed by the legacy GUID because these rows are not yet in the thing
+ table. Post-transform this becomes an integer thing_id lookup.
+ """
+ well = get_geothermal_well_by_id(session, well_data_id)
+ if well is None:
+ raise PydanticStyleException(
+ status_code=HTTP_404_NOT_FOUND,
+ detail=[
+ {
+ "loc": ["path", "well_data_id"],
+ "msg": f"Geothermal well with WellDataID {well_data_id} not found.",
+ "type": "value_error",
+ "input": {"well_data_id": str(well_data_id)},
+ }
+ ],
+ )
+ return well
+
# ============= EOF =============================================
diff --git a/core/initializers.py b/core/initializers.py
index 14a246cb4..ee0fecbe2 100644
--- a/core/initializers.py
+++ b/core/initializers.py
@@ -216,6 +216,7 @@ def register_api_routes(app):
from api.ngwmn import router as ngwmn_router
from api.feedback import router as feedback_router
from api.disclaimer import router as disclaimer_router
+ from api.geothermal import router as geothermal_router
app.include_router(asset_router)
app.include_router(author_router)
@@ -230,6 +231,9 @@ def register_api_routes(app):
app.include_router(sample_router)
app.include_router(sensor_router)
app.include_router(search_router)
+ # geothermal shares the /thing prefix; register before thing_router so its
+ # explicit /thing/geothermal-well routes take precedence over /thing/{id}
+ app.include_router(geothermal_router)
app.include_router(thing_router)
app.include_router(ngwmn_router)
app.include_router(feedback_router)
diff --git a/schemas/geothermal.py b/schemas/geothermal.py
index 43b7486e4..1292fa916 100644
--- a/schemas/geothermal.py
+++ b/schemas/geothermal.py
@@ -13,7 +13,44 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
-from pydantic import BaseModel
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict
+
+
+class GeothermalWellResponse(BaseModel):
+ """Read model for a geothermal well sourced from the legacy NM_Wells mirror.
+
+ NOTE: This currently reads directly from the ``NMW_WellHeaders`` /
+ ``NMW_WellLocations`` staging tables (see ``db/nmw_legacy.py``). Once the
+ NM_Wells -> Ocotillo transform lands, these rows will be backed by the
+ ``thing`` table and ``thing_id`` will be populated. Until then ``thing_id``
+ is always ``None`` and ``well_data_id`` (legacy GUID) is the identifier.
+ """
+
+ model_config = ConfigDict(from_attributes=True)
+
+ well_data_id: UUID # legacy NMW_WellHeaders.WellDataID
+ thing_id: int | None = None # populated after NM_Wells -> thing transform
+
+ api: str | None = None
+ name: str | None = None # cur_well_nam
+ well_number: str | None = None # cur_well_num
+ well_class: str | None = None
+ well_type: str | None = None
+ status: str | None = None # cur_status
+ operator: str | None = None # cur_operatr
+ owner: str | None = None # cur_owner
+ total_depth: float | None = None
+ completion_date: datetime | None = None # compl_date
+ has_geothermal_data: bool | None = None # gthrm_exist
+
+ # location, joined from NMW_WellLocations on WellDataID
+ county: str | None = None
+ state: str | None = None
+ latitude: float | None = None # lat_dd83
+ longitude: float | None = None # long_dd83
class CreateTemperatureProfile(BaseModel):
diff --git a/services/geothermal_helper.py b/services/geothermal_helper.py
new file mode 100644
index 000000000..5fc10f9fd
--- /dev/null
+++ b/services/geothermal_helper.py
@@ -0,0 +1,99 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Read-side helpers for geothermal wells.
+
+TEMPORARY SOURCE: these read straight from the legacy NM_Wells staging mirror
+(``db/nmw_legacy.py``). A "geothermal well" is a ``NMW_WellHeaders`` row whose
+``GthrmExist`` flag is set. Location (lat/long/county/state) is joined from
+``NMW_WellLocations`` on ``WellDataID``.
+
+Once the NM_Wells -> Ocotillo transform exists, swap the source to the ``thing``
+table and populate ``thing_id`` on the response. Keeping the DB access behind
+this helper is what makes that swap a one-file change.
+"""
+
+from uuid import UUID
+
+from sqlalchemy import select
+
+from db.nmw_legacy import NMW_WellHeaders, NMW_WellLocations
+from schemas.geothermal import GeothermalWellResponse
+
+
+def _base_query():
+ """Header rows flagged geothermal, left-joined to their location."""
+ return (
+ select(NMW_WellHeaders, NMW_WellLocations)
+ .outerjoin(
+ NMW_WellLocations,
+ NMW_WellHeaders.well_data_id == NMW_WellLocations.well_data_id,
+ )
+ .where(NMW_WellHeaders.gthrm_exist == 1)
+ )
+
+
+def _to_response(header: NMW_WellHeaders, location: NMW_WellLocations | None):
+ return GeothermalWellResponse(
+ well_data_id=header.well_data_id,
+ thing_id=None, # not yet linked; see NM_Wells -> thing transform
+ api=header.api,
+ name=header.cur_well_nam,
+ well_number=header.cur_well_num,
+ well_class=header.well_class,
+ well_type=header.well_type,
+ status=header.cur_status,
+ operator=header.cur_operatr,
+ owner=header.cur_owner,
+ total_depth=header.total_depth,
+ completion_date=header.compl_date,
+ has_geothermal_data=bool(header.gthrm_exist),
+ county=location.county if location else None,
+ state=location.state if location else None,
+ latitude=location.lat_dd83 if location else None,
+ longitude=location.long_dd83 if location else None,
+ )
+
+
+def get_geothermal_wells_query(
+ county: str | None = None,
+ name_contains: str | None = None,
+):
+ """Build the list query; returned as a SQLAlchemy select for pagination."""
+ sql = _base_query()
+ if county:
+ sql = sql.where(NMW_WellLocations.county == county)
+ if name_contains:
+ sql = sql.where(NMW_WellHeaders.cur_well_nam.ilike(f"%{name_contains}%"))
+ return sql.order_by(NMW_WellHeaders.cur_well_nam)
+
+
+def geothermal_wells_transformer(rows) -> list[dict]:
+ """Map (header, location) Rows -> GeothermalWellResponse dicts."""
+ return [_to_response(header, location).model_dump() for header, location in rows]
+
+
+def get_geothermal_well_by_id(session, well_data_id: UUID):
+ """Return a single geothermal well by legacy WellDataID, or None."""
+ row = session.execute(
+ _base_query().where(NMW_WellHeaders.well_data_id == well_data_id)
+ ).first()
+ if row is None:
+ return None
+ header, location = row
+ return _to_response(header, location)
+
+
+# ============= EOF =============================================
diff --git a/transfers/seed_geothermal.py b/transfers/seed_geothermal.py
new file mode 100644
index 000000000..a6bdddd5f
--- /dev/null
+++ b/transfers/seed_geothermal.py
@@ -0,0 +1,234 @@
+"""Populate the legacy NM_Wells staging mirror with fake geothermal data.
+
+Seeds the geothermal chain so the /thing/geothermal-well endpoint and the OGC
+geothermal views (BHT, temperature-depth, heat-flow) all return data:
+
+ NMW_WellHeaders (GthrmExist=1)
+ -> NMW_WellLocations (lat/long/county/state)
+ -> NMW_WellRecords
+ -> NMW_WellSamples
+ -> NMW_GtBhtHeaders -> NMW_GtBhtData (bottom-hole temps)
+ -> NMW_GtTempDepths (temp-vs-depth profile)
+ -> NMW_GtSumHeatFlow (summary heat flow)
+
+TEMPORARY: this seeds the staging mirror, not the Ocotillo `thing` table. Once
+the NM_Wells -> Ocotillo transform exists, seed `thing` instead (see seed.py).
+
+Run with:
+ docker compose exec -T app python -m transfers.seed_geothermal
+"""
+
+import random
+import uuid
+
+from faker import Faker
+from sqlalchemy import select
+
+from db.engine import session_ctx
+from db.nmw_legacy import (
+ NMW_GtBhtData,
+ NMW_GtBhtHeaders,
+ NMW_GtSumHeatFlow,
+ NMW_GtTempDepths,
+ NMW_WellHeaders,
+ NMW_WellLocations,
+ NMW_WellRecords,
+ NMW_WellSamples,
+)
+
+fake = Faker()
+Faker.seed(42)
+random.seed(42)
+
+# Integer PKs on heap tables (OBJECTID). Base high enough to never collide with
+# real dump rows loaded by transfers.nmw_mirror_transfer.
+_OID_BASE = 9_000_000
+
+# Rough NM bounding-box anchors (lat, lon), mirrors transfers/seed.py.
+NEW_MEXICO_BOUNDS = [
+ (36.9, -106.6), # Taos
+ (35.1, -106.6), # Albuquerque
+ (32.3, -106.8), # Las Cruces
+ (34.4, -103.2), # Clovis
+ (36.7, -108.2), # Farmington
+]
+COUNTIES = ["Bernalillo", "Santa Fe", "Doña Ana", "Sandoval", "Grant", "Otero"]
+
+
+def geothermal_data_exists() -> bool:
+ with session_ctx() as s:
+ return (
+ s.scalar(
+ select(NMW_WellHeaders.well_data_id)
+ .where(NMW_WellHeaders.gthrm_exist == 1)
+ .limit(1)
+ )
+ is not None
+ )
+
+
+def seed_geothermal(n: int = 8, skip_if_exists: bool = True):
+ """Seed ~`n` geothermal wells and their child measurements."""
+ if skip_if_exists and geothermal_data_exists():
+ print("Geothermal data exists; skipping seeding.")
+ return
+
+ oid = _OID_BASE
+
+ with session_ctx() as s:
+ for i in range(n):
+ well_data_id = uuid.uuid4()
+ base_lat, base_lon = random.choice(NEW_MEXICO_BOUNDS)
+ lat = round(base_lat + random.uniform(-0.3, 0.3), 6)
+ lon = round(base_lon + random.uniform(-0.3, 0.3), 6)
+ total_depth = round(random.uniform(800, 12000), 1)
+
+ s.add(
+ NMW_WellHeaders(
+ well_data_id=well_data_id,
+ api=fake.numerify("30-###-#####"),
+ well_class="Oil & Gas",
+ well_type=random.choice(["Exploration", "Production", "Wildcat"]),
+ well_orient="Vertical",
+ cur_well_nam=f"GEOTHERMAL-{i + 1:04d}",
+ cur_well_num=str(random.randint(1, 30)),
+ cur_status=random.choice(["Active", "Plugged", "Abandoned"]),
+ cur_operatr=fake.company(),
+ cur_owner=fake.company(),
+ total_depth=total_depth,
+ compl_date=fake.date_time_between("-40y", "-1y"),
+ gthrm_exist=1, # flags this as a geothermal well
+ comments="Seeded geothermal well (fake data).",
+ )
+ )
+ # The mirror columns are plain (no ORM ForeignKey), so SQLAlchemy
+ # cannot dependency-order inserts. Flush each parent tier before its
+ # children so the DB-level FK constraints (V10) are satisfied.
+ s.flush()
+
+ oid += 1
+ s.add(
+ NMW_WellLocations(
+ object_id=oid,
+ well_data_id=well_data_id,
+ state="NM",
+ county=random.choice(COUNTIES),
+ lat_dd83=lat,
+ long_dd83=lon,
+ comments="Seeded location (fake data).",
+ )
+ )
+
+ # records -> samples chain
+ recrd_set_id = uuid.uuid4()
+ oid += 1
+ s.add(
+ NMW_WellRecords(
+ object_id=oid,
+ recrd_set_id=recrd_set_id,
+ well_data_id=well_data_id,
+ recrd_class="Geothermal",
+ action_date=fake.date_time_between("-40y", "-1y"),
+ well_name=f"GEOTHERMAL-{i + 1:04d}",
+ comments="Seeded record (fake data).",
+ )
+ )
+ s.flush()
+
+ sampl_set_id = uuid.uuid4()
+ oid += 1
+ s.add(
+ NMW_WellSamples(
+ object_id=oid,
+ sampl_set_id=sampl_set_id,
+ recrdset_id=recrd_set_id,
+ smp_set_name=f"GT-SAMPLE-{i + 1:04d}",
+ sampl_class="data",
+ geothermal=1,
+ sample_date=fake.date_time_between("-40y", "-1y"),
+ from_depth=0.0,
+ to_depth=total_depth,
+ smp_dp_unt="ft",
+ notes="Seeded sample set (fake data).",
+ )
+ )
+ s.flush()
+
+ # bottom-hole temperature header + readings
+ bht_guid = uuid.uuid4()
+ s.add(
+ NMW_GtBhtHeaders(
+ bht_guid=bht_guid,
+ sampl_set_id=sampl_set_id,
+ bore_dia=round(random.uniform(6, 12), 2),
+ bore_units="in",
+ drill_fluid="mud",
+ temp_unit="F",
+ notes="Seeded BHT header (fake data).",
+ )
+ )
+ s.flush()
+ for _ in range(random.randint(1, 3)):
+ oid += 1
+ depth = round(random.uniform(500, total_depth), 1)
+ s.add(
+ NMW_GtBhtData(
+ object_id=oid,
+ bht_guid=bht_guid,
+ depth=depth,
+ bht=round(70 + depth * 0.015 + random.uniform(-5, 5), 1),
+ temp_unit="F",
+ hrs_snce_cir=round(random.uniform(1, 24), 1),
+ date_measrd=fake.date_time_between("-40y", "-1y"),
+ )
+ )
+
+ # temperature-vs-depth profile
+ for step in range(1, random.randint(3, 6)):
+ oid += 1
+ depth = round(total_depth * step / 6, 1)
+ s.add(
+ NMW_GtTempDepths(
+ object_id=oid,
+ sampl_set_id=sampl_set_id,
+ depth=depth,
+ temp=round(70 + depth * 0.016 + random.uniform(-3, 3), 1),
+ temp_unit="F",
+ intrvl_grad=round(random.uniform(15, 40), 2),
+ )
+ )
+
+ # summary heat flow
+ oid += 1
+ s.add(
+ NMW_GtSumHeatFlow(
+ object_id=oid,
+ recrd_set_id=recrd_set_id,
+ sampl_set_id=sampl_set_id,
+ from_depth=0.0,
+ to_depth=total_depth,
+ depth_unit="ft",
+ therml_grad=round(random.uniform(20, 45), 2),
+ grad_unit="C/k", # GradUnit is varchar(3)
+ therml_cond=round(random.uniform(1.5, 3.5), 2),
+ tcond_unit="W/m", # TCondUnit is varchar(3)
+ heat_flow=round(random.uniform(40, 120), 1),
+ ht_flow_unit="HFU", # HtFlowUnit is varchar(3)
+ quality="B",
+ comments="Seeded heat flow (fake data).",
+ )
+ )
+
+ try:
+ s.commit()
+ print(f"Geothermal seed complete: {n} wells + child measurements.")
+ except Exception as e:
+ s.rollback()
+ print(f"Error committing geothermal seed data: {e}")
+ raise
+
+ print("Geothermal seeding finished.")
+
+
+if __name__ == "__main__":
+ seed_geothermal(8, skip_if_exists=True)
From 21090b50e90eea9d52295cc68b18c2926e9dd9a5 Mon Sep 17 00:00:00 2001
From: Kelsey Smuczynski
Date: Fri, 7 Aug 2026 12:27:27 -0600
Subject: [PATCH 042/151] feat(lexicon): add organization and sort organization
category alphabetically
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add Lower Rio Grande Public Water Works Authority to the organization lexicon category. Sort all organization-only terms into a single alphabetical block.
Also add `domain` to the declared packages in pyproject.toml so the editable install finder includes it — omitting it caused `ModuleNotFoundError` when running `oco initialize-lexicon` after the domain layer was introduced in #814.
---
core/lexicon.json | 1219 +++++++++++++++++++++++----------------------
pyproject.toml | 2 +-
2 files changed, 614 insertions(+), 607 deletions(-)
diff --git a/core/lexicon.json b/core/lexicon.json
index cf2396298..813428dfb 100644
--- a/core/lexicon.json
+++ b/core/lexicon.json
@@ -2583,50 +2583,50 @@
"categories": [
"organization"
],
- "term": "City of Aztec",
- "definition": "City of Aztec"
+ "term": "A&T Pump & Well Service, LLC",
+ "definition": "A&T Pump & Well Service, LLC"
},
{
"categories": [
"organization"
],
- "term": "Daybreak Investments",
- "definition": "Daybreak Investments"
+ "term": "A. G. Wassenaar, Inc",
+ "definition": "A. G. Wassenaar, Inc"
},
{
"categories": [
"organization"
],
- "term": "Vallecitos HOA",
- "definition": "Vallecitos HOA"
+ "term": "Abeyta Engineering, Inc",
+ "definition": "Abeyta Engineering, Inc"
},
{
"categories": [
"organization"
],
- "term": "SFC, Santa Fe Animal Shelter",
- "definition": "Santa Fe County, Santa Fe Animal Shelter"
+ "term": "Adobe Ranch",
+ "definition": "Adobe Ranch"
},
{
"categories": [
"organization"
],
- "term": "El Guicu Ditch Association",
- "definition": "El Guicu Ditch Association"
+ "term": "Agua Fria Community Water Association",
+ "definition": "Agua Fria Community Water Association"
},
{
"categories": [
"organization"
],
- "term": "Santa Fe Municipal Airport",
- "definition": "Santa Fe Municipal Airport"
+ "term": "Agua Sana MWCD",
+ "definition": "Agua Sana MWCD"
},
{
"categories": [
"organization"
],
- "term": "Uluru Development",
- "definition": "Uluru Development"
+ "term": "Agua Sana WUA",
+ "definition": "Agua Sana Water Users Assn."
},
{
"categories": [
@@ -2639,792 +2639,729 @@
"categories": [
"organization"
],
- "term": "Santa Fe Downs Resort",
- "definition": "Santa Fe Downs Resort"
+ "term": "Alto Alps HOA",
+ "definition": "Alto Alps Homeowners Association"
},
{
"categories": [
"organization"
],
- "term": "City of Truth or Consequences, WWTP",
- "definition": "City of Truth or Consequences, WWTP"
+ "term": "AMEC",
+ "definition": "AMEC"
},
{
"categories": [
"organization"
],
- "term": "Riverbend Hotsprings",
- "definition": "Riverbend Hotsprings"
+ "term": "Anasazi Trails Water Co-op",
+ "definition": "Anasazi Trails Water Cooperative"
},
{
"categories": [
"organization"
],
- "term": "Armendaris Ranch",
- "definition": "Armendaris Ranch"
+ "term": "Apache Gap Ranch",
+ "definition": "Apache Gap Ranch"
},
{
"categories": [
"organization"
],
- "term": "El Paso Water",
- "definition": "El Paso Water"
+ "term": "Armendaris Ranch",
+ "definition": "Armendaris Ranch"
},
{
"categories": [
"organization"
],
- "term": "BLM, Socorro Field Office",
- "definition": "BLM, Socorro Field Office"
+ "term": "Aspendale Mountain Retreat",
+ "definition": "Aspendale Mountain Retreat"
},
{
"categories": [
"organization"
],
- "term": "USFWS",
- "definition": "US Fish & Wildlife Service"
+ "term": "Augustin Plains Ranch LLC",
+ "definition": "Augustin Plains Ranch LLC"
},
{
"categories": [
"organization"
],
- "term": "Sile MDWCA",
- "definition": "Sile Municipal Domestic Water Assn."
+ "term": "B & B Cattle Co",
+ "definition": "B & B Cattle Co"
},
{
"categories": [
"organization"
],
- "term": "Pena Blanca Water & Sanitation District",
- "definition": "Pena Blanca Water & Sanitation District"
+ "term": "Balleau Groundwater, Inc",
+ "definition": "Balleau Groundwater, Inc"
},
{
"categories": [
"organization"
],
- "term": "Town of Questa",
- "definition": "Town of Questa"
+ "term": "Bayard",
+ "definition": "Bayard Municipal Water"
},
{
"categories": [
"organization"
],
- "term": "Town of Cerro",
- "definition": "Town of Cerro"
+ "term": "Bernalillo County",
+ "definition": "Bernalillo County"
},
{
"categories": [
"organization"
],
- "term": "Cerro MDWCA",
- "definition": "Cerro MDWCA"
+ "term": "Berridge Distributing Company",
+ "definition": "Berridge Distributing Company"
},
{
"categories": [
"organization"
],
- "term": "Farr Cattle Company",
- "definition": "Farr Cattle Company (Farr Ranch)"
+ "term": "Bike Ranch",
+ "definition": "Bike Ranch"
},
{
"categories": [
"organization"
],
- "term": "Carrizozo Orchard",
- "definition": "Carrizozo Orchard"
+ "term": "Bishop's Lodge",
+ "definition": "Bishop's Lodge"
},
{
"categories": [
"organization"
],
- "term": "White Oaks Pottery",
- "definition": "White Oaks Pottery"
+ "term": "BLM",
+ "definition": "Bureau of Land Management"
},
{
"categories": [
"organization"
],
- "term": "USFS, Kiowa Grasslands",
- "definition": "USFS, Kiowa Grasslands"
+ "term": "BLM Taos Office",
+ "definition": "Bureau of Land Management Taos Office"
},
{
"categories": [
"organization"
],
- "term": "Cloud Country West Subdivision",
- "definition": "Cloud Country West Subdivision"
+ "term": "BLM, Roswell Office",
+ "definition": "BLM, Roswell Office"
},
{
"categories": [
"organization"
],
- "term": "Chama West WUA",
- "definition": "Chama West Water Users Assn."
+ "term": "BLM, Socorro Field Office",
+ "definition": "BLM, Socorro Field Office"
},
{
"categories": [
"organization"
],
- "term": "El Rito Regional Water and Waste Water Association",
- "definition": "El Rito Regional Water + Waste Water Association"
+ "term": "Bluewater Acres Domestic WUA",
+ "definition": "Bluewater Acres Domestic Water Users Assn."
},
{
"categories": [
"organization"
],
- "term": "El Rito MDWCA",
- "definition": "El Rito MDWCA"
+ "term": "Bluewater Lake MDWCA",
+ "definition": "Bluewater Lake MDWCA"
},
{
"categories": [
"organization"
],
- "term": "West Rim MDWUA",
- "definition": "West Rim MDWUA"
+ "term": "Bonanza Creek Ranch",
+ "definition": "Bonanza Creek Ranch"
},
{
"categories": [
"organization"
],
- "term": "Village of Willard",
- "definition": "Village of Willard"
+ "term": "Bourbon Grill at El Gancho",
+ "definition": "Bourbon Grill at El Gancho"
},
{
"categories": [
"organization"
],
- "term": "Quemado Municipal Water & SWA",
- "definition": "Quemado Mutual Water and Sewage Works Association"
+ "term": "Brazos MDWCA",
+ "definition": "Brazos Mutual Domestic Water Consumers Assn."
},
{
"categories": [
"organization"
],
- "term": "Coyote Creek MDWUA",
- "definition": "Coyote Creek MDWUA"
+ "term": "Bug Scuffle Water Association",
+ "definition": "Bug Scuffle Water Association"
},
{
"categories": [
"organization"
],
- "term": "Lamy MDWCA",
- "definition": "Lamy Mutual Domestic Water Assn."
+ "term": "Campbell Ranch",
+ "definition": "Campbell Ranch"
},
{
"categories": [
"organization"
],
- "term": "La Joya CWDA",
- "definition": "La Joya CWDA"
+ "term": "Canada Los Alamos MDWCA",
+ "definition": "Canada Los Alamos MDWCA"
},
{
"categories": [
"organization"
],
- "term": "NM Firefighters Training Academy",
- "definition": "NM Firefighters Training Academy"
+ "term": "Canjilon Mutual Domestic Water System",
+ "definition": "Canjilon Mutual Domestic Water System"
},
{
"categories": [
"organization"
],
- "term": "Cebolleta Land Grant",
- "definition": "Cebolleta Land Grant"
+ "term": "Canon MDWCA",
+ "definition": "Canon Mutual Domestic Water Consumer Assn."
},
{
"categories": [
"organization"
],
- "term": "Madrid Water Co-op",
- "definition": "Madrid Water Co-op"
+ "term": "Capitol Ford Santa Fe",
+ "definition": "Capitol Ford Santa Fe"
},
{
"categories": [
"organization"
],
- "term": "Sun Valley Water and Sanitation",
- "definition": "Sun Valley Water and Sanitation"
+ "term": "Carrizozo Municipal Water",
+ "definition": "Carrizozo Municipal Water"
},
{
"categories": [
"organization"
],
- "term": "Bluewater Lake MDWCA",
- "definition": "Bluewater Lake MDWCA"
+ "term": "Carrizozo Orchard",
+ "definition": "Carrizozo Orchard"
},
{
"categories": [
"organization"
],
- "term": "Bluewater Acres Domestic WUA",
- "definition": "Bluewater Acres Domestic Water Users Assn."
+ "term": "Casas Adobes MDWCA",
+ "definition": "Casas Adobes Mutual Domestic"
},
{
"categories": [
"organization"
],
- "term": "Lybrook MDWCA",
- "definition": "Lybrook Municipal"
+ "term": "CDM Smith",
+ "definition": "CDM Smith"
},
{
"categories": [
"organization"
],
- "term": "New Mexico Museum of Natural History",
- "definition": "New Mexico Museum of Natural History"
+ "term": "CDWR",
+ "definition": "Colorado Division of Water Resources"
},
{
"categories": [
"organization"
],
- "term": "Hillsboro MDWCA",
- "definition": "Hillsboro Mutual Domestic Water Consumer Assn."
+ "term": "Cebolla Mutual Domestic",
+ "definition": "Cebolla Mutual Domestic"
},
{
"categories": [
"organization"
],
- "term": "Tyrone MDWCA",
- "definition": "Tyrone Mutual Domestic Water Assn."
+ "term": "Cebolleta Land Grant",
+ "definition": "Cebolleta Land Grant"
},
{
"categories": [
"organization"
],
- "term": "Santa Clara Water System",
- "definition": "Santa Clara Water System"
+ "term": "Cemex, Inc",
+ "definition": "Cemex, Inc"
},
{
"categories": [
"organization"
],
- "term": "Casas Adobes MDWCA",
- "definition": "Casas Adobes Mutual Domestic"
+ "term": "Cerro Community Center",
+ "definition": "Cerro Community Center"
},
{
"categories": [
"organization"
],
- "term": "Lake Roberts WUA",
- "definition": "Lake Roberts Water Assn."
+ "term": "Cerro MDWCA",
+ "definition": "Cerro MDWCA"
},
{
"categories": [
"organization"
],
- "term": "El Creston MDWCA",
- "definition": "El Creston MDWCA"
+ "term": "CH2M Hill",
+ "definition": "CH2M Hill"
},
{
"categories": [
"organization"
],
- "term": "Reserve Municipality Water Works",
- "definition": "Reserve Municipality Water Works"
+ "term": "Chama West WUA",
+ "definition": "Chama West Water Users Assn."
},
{
"categories": [
"organization"
],
- "term": "Town of Estancia",
- "definition": "Town of Estancia"
+ "term": "Chamita MDWCA",
+ "definition": "Chamita Mutual Domestic Water Consumers Assn."
},
{
"categories": [
"organization"
],
- "term": "Pie Town MDWCA",
- "definition": "Pie Town MDWCA"
+ "term": "Chevron",
+ "definition": "Chevron"
},
{
"categories": [
"organization"
],
- "term": "Roosevelt SWCD",
- "definition": "Roosevelt Soil & Water Conservation District"
+ "term": "Chihuahuan Desert Rangeland Research Center (CDRRC)",
+ "definition": "Chihuahuan Desert Rangeland Research Center (CDRRC)"
},
{
"categories": [
"organization"
],
- "term": "Otis MDWCA",
- "definition": "Otis Mutual Domestic"
+ "term": "Chiricahua Desert Museum",
+ "definition": "Chiricahua Desert Museum"
},
{
"categories": [
"organization"
],
- "term": "White Cliffs MDWUA",
- "definition": "White Cliffs MDWUA"
+ "term": "Chupadero MDWCA",
+ "definition": "Chupadero MDWCA"
},
{
"categories": [
"organization"
],
- "term": "Vista Linda Water Co-op",
- "definition": "Vista Linda Water Co-op"
+ "term": "Cielo Lumbre HOA",
+ "definition": "Cielo Lumbre HOA"
},
{
"categories": [
"organization"
],
- "term": "Anasazi Trails Water Co-op",
- "definition": "Anasazi Trails Water Cooperative"
+ "term": "Circle Cross Ranch",
+ "definition": "Circle Cross Ranch"
},
{
"categories": [
"organization"
],
- "term": "Canon MDWCA",
- "definition": "Canon Mutual Domestic Water Consumer Assn."
+ "term": "City of Alamogordo",
+ "definition": "City of Alamogordo"
},
{
"categories": [
"organization"
],
- "term": "Placitas Trails Water Co-op",
- "definition": "Placitas Trails Water Coop"
+ "term": "City of Aztec",
+ "definition": "City of Aztec"
},
{
"categories": [
"organization"
],
- "term": "BLM, Roswell Office",
- "definition": "BLM, Roswell Office"
+ "term": "City of Portales, Public Works Dept.",
+ "definition": "City of Portales, Public Works Dept."
},
{
"categories": [
"organization"
],
- "term": "Forked Lightning Ranch",
- "definition": "Forked Lightning Ranch"
+ "term": "City of Santa Fe",
+ "definition": "City of Santa Fe"
},
{
"categories": [
"organization"
],
- "term": "Cottonwood RWA",
- "definition": "Cottonwood Rural Water Assn."
+ "term": "City of Santa Fe WWTP",
+ "definition": "City of Santa Fe WWTP"
},
{
"categories": [
"organization"
],
- "term": "Pinon Ridge WUA",
- "definition": "Pinon Ridge Water Users Association"
+ "term": "City of Santa Fe, Municipal Recreation Complex",
+ "definition": "City of Santa Fe, Municipal Recreation Complex"
},
{
"categories": [
"organization"
],
- "term": "McSherry Farms",
- "definition": "McSherry Farms"
+ "term": "City of Santa Fe, Sangre de Cristo Water Co.",
+ "definition": "City of Santa Fe, Sangre de Cristo Water Co."
},
{
"categories": [
"organization"
],
- "term": "Agua Sana WUA",
- "definition": "Agua Sana Water Users Assn."
+ "term": "City of Socorro",
+ "definition": "City of Socorro"
},
{
"categories": [
"organization"
],
- "term": "Chamita MDWCA",
- "definition": "Chamita Mutual Domestic Water Consumers Assn."
+ "term": "City of Truth or Consequences, WWTP",
+ "definition": "City of Truth or Consequences, WWTP"
},
{
"categories": [
"organization"
],
- "term": "W Spear-bar Ranch",
- "definition": "W Spear-bar Ranch"
- },
- {
- "categories": [
- "organization"
- ],
- "term": "Village of Capitan",
- "definition": "Village of Capitan"
- },
- {
- "categories": [
- "organization"
- ],
- "term": "Brazos MDWCA",
- "definition": "Brazos Mutual Domestic Water Consumers Assn."
- },
- {
- "categories": [
- "organization"
- ],
- "term": "Alto Alps HOA",
- "definition": "Alto Alps Homeowners Association"
- },
- {
- "categories": [
- "organization"
- ],
- "term": "Chiricahua Desert Museum",
- "definition": "Chiricahua Desert Museum"
- },
- {
- "categories": [
- "organization"
- ],
- "term": "Bike Ranch",
- "definition": "Bike Ranch"
- },
- {
- "categories": [
- "organization"
- ],
- "term": "Hachita MDWCA",
- "definition": "Hachita MDWCA"
- },
- {
- "categories": [
- "organization"
- ],
- "term": "Carrizozo Municipal Water",
- "definition": "Carrizozo Municipal Water"
- },
- {
- "categories": [
- "organization"
- ],
- "term": "Dunhill Ranch",
- "definition": "Dunhill Ranch"
- },
- {
- "categories": [
- "organization"
- ],
- "term": "Santa Fe Conservation Trust",
- "definition": "Santa Fe Conservation Trust"
+ "term": "Cloud Country West Subdivision",
+ "definition": "Cloud Country West Subdivision"
},
{
"categories": [
"organization"
],
- "term": "NMSU",
- "definition": "New Mexico State University"
+ "term": "Commonwealth Conservancy",
+ "definition": "Commonwealth Conservancy"
},
{
"categories": [
"organization"
],
- "term": "USGS",
- "definition": "US Geological Survey"
+ "term": "Corbin Consulting, Inc",
+ "definition": "Corbin Consulting, Inc"
},
{
"categories": [
"organization"
],
- "term": "TWDB",
- "definition": "Texas Water Development Board"
+ "term": "Costilla MDWCA",
+ "definition": "Costilla MDWCA"
},
{
"categories": [
"organization"
],
- "term": "NMED",
- "definition": "New Mexico Environment Department"
+ "term": "Cottonwood RWA",
+ "definition": "Cottonwood Rural Water Assn."
},
{
"categories": [
"organization"
],
- "term": "NMOSE",
- "definition": "New Mexico Office of the State Engineer"
+ "term": "Country Club Garden Mobile Home Park",
+ "definition": "Country Club Garden Mobile Home Park"
},
{
"categories": [
"organization"
],
- "term": "NMBGMR",
- "definition": "New Mexico Bureau of Geology and Mineral Resources"
+ "term": "Coyote Creek MDWUA",
+ "definition": "Coyote Creek MDWUA"
},
{
"categories": [
"organization"
],
- "term": "Bernalillo County",
- "definition": "Bernalillo County"
+ "term": "Crossroads Cattle Co., Ltd",
+ "definition": "Crossroads Cattle Co., Ltd"
},
{
"categories": [
"organization"
],
- "term": "BLM",
- "definition": "Bureau of Land Management"
+ "term": "Daniel B. Stephens & Associates, Inc",
+ "definition": "Daniel B. Stephens & Associates, Inc"
},
{
"categories": [
"organization"
],
- "term": "BLM Taos Office",
- "definition": "Bureau of Land Management Taos Office"
+ "term": "Daybreak Investments",
+ "definition": "Daybreak Investments"
},
{
"categories": [
"organization"
],
- "term": "SFC",
- "definition": "Santa Fe County"
+ "term": "Desert Village RV & Mobile Home Park",
+ "definition": "Desert Village RV & Mobile Home Park"
},
{
"categories": [
"organization"
],
- "term": "SFC, Fire Facilities",
- "definition": "Santa Fe County, Fire Facilities"
+ "term": "Double H Ranch",
+ "definition": "Double H Ranch"
},
{
"categories": [
"organization"
],
- "term": "SFC, Utilities Dept.",
- "definition": "Santa Fe County, Utilities Dept."
+ "term": "Dunhill Ranch",
+ "definition": "Dunhill Ranch"
},
{
"categories": [
"organization"
],
- "term": "SFC, Valle Vista Water Utility, Inc.",
- "definition": "Santa Fe County, Valle Vista Water Utility, Inc."
+ "term": "E.A. Meadows East",
+ "definition": "E.A. Meadows East"
},
{
"categories": [
"organization"
],
- "term": "City of Santa Fe",
- "definition": "City of Santa Fe"
+ "term": "East Rio Arriba SWCD",
+ "definition": "East Rio Arriba SWCD"
},
{
"categories": [
"organization"
],
- "term": "City of Santa Fe WWTP",
- "definition": "City of Santa Fe WWTP"
+ "term": "El Camino Realty, Inc",
+ "definition": "El Camino Realty, Inc"
},
{
"categories": [
"organization"
],
- "term": "City of Santa Fe, Municipal Recreation Complex",
- "definition": "City of Santa Fe, Municipal Recreation Complex"
+ "term": "El Creston MDWCA",
+ "definition": "El Creston MDWCA"
},
{
"categories": [
"organization"
],
- "term": "City of Santa Fe, Sangre de Cristo Water Co.",
- "definition": "City of Santa Fe, Sangre de Cristo Water Co."
+ "term": "El Guicu Ditch Association",
+ "definition": "El Guicu Ditch Association"
},
{
"categories": [
"organization"
],
- "term": "NMISC",
- "definition": "New Mexico Interstate Stream Commission"
+ "term": "El Paso Water",
+ "definition": "El Paso Water"
},
{
"categories": [
"organization"
],
- "term": "PVACD",
- "definition": "Pecos Valley Artesian Conservancy District"
+ "term": "El Prado HOA",
+ "definition": "El Prado HOA"
},
{
"categories": [
"organization"
],
- "term": "Bayard",
- "definition": "Bayard Municipal Water"
+ "term": "El Prado Municipal Water",
+ "definition": "El Prado Municipal Water"
},
{
"categories": [
"organization"
],
- "term": "SNL",
- "definition": "Sandia National Laboratories"
+ "term": "El Rancho de las Golondrinas",
+ "definition": "El Rancho de las Golondrinas"
},
{
"categories": [
"organization"
],
- "term": "USFS",
- "definition": "United States Forest Service"
+ "term": "El Rito Canyon MDWCA",
+ "definition": "El Rito Canyon MDWCA"
},
{
"categories": [
"organization"
],
- "term": "NMT",
- "definition": "New Mexico Tech"
+ "term": "El Rito MDWCA",
+ "definition": "El Rito MDWCA"
},
{
"categories": [
"organization"
],
- "term": "NPS",
- "definition": "National Park Service"
+ "term": "El Rito Regional Water and Waste Water Association",
+ "definition": "El Rito Regional Water + Waste Water Association"
},
{
"categories": [
"organization"
],
- "term": "NMRWA",
- "definition": "New Mexico Rural Water Association"
+ "term": "Eldorado Area Water & Sanitation District",
+ "definition": "Eldorado Area Water & Sanitation District"
},
{
"categories": [
"organization"
],
- "term": "NMDOT",
- "definition": "New Mexico Department of Transportation"
+ "term": "Encantado Enterprises",
+ "definition": "Encantado Enterprises"
},
{
"categories": [
"organization"
],
- "term": "Taos SWCD",
- "definition": "Taos Soil and Water Conservation District"
+ "term": "EnecoTech",
+ "definition": "EnecoTech"
},
{
"categories": [
"organization"
],
- "term": "Otero SWCD",
- "definition": "Otero Soil and Water Conservation District"
+ "term": "Estrella Concepts LLC",
+ "definition": "Estrella Concepts LLC"
},
{
"categories": [
"organization"
],
- "term": "Northeastern SWCD",
- "definition": "Northeastern Soil and Water Conservation District"
+ "term": "Faith Engineering, Inc",
+ "definition": "Faith Engineering, Inc"
},
{
"categories": [
"organization"
],
- "term": "CDWR",
- "definition": "Colorado Division of Water Resources"
+ "term": "Farr Cattle Company",
+ "definition": "Farr Cattle Company (Farr Ranch)"
},
{
"categories": [
"organization"
],
- "term": "Pendaries Village",
- "definition": "Pendaries Village"
+ "term": "Fire Water Lodge",
+ "definition": "Fire Water Lodge"
},
{
"categories": [
"organization"
],
- "term": "A&T Pump & Well Service, LLC",
- "definition": "A&T Pump & Well Service, LLC"
+ "term": "Ford County Land & Cattle Company, Inc",
+ "definition": "Ford County Land & Cattle Company, Inc"
},
{
"categories": [
"organization"
],
- "term": "A. G. Wassenaar, Inc",
- "definition": "A. G. Wassenaar, Inc"
+ "term": "Forked Lightning Ranch",
+ "definition": "Forked Lightning Ranch"
},
{
"categories": [
"organization"
],
- "term": "AMEC",
- "definition": "AMEC"
+ "term": "Foster Well Service, Inc",
+ "definition": "Foster Well Service, Inc"
},
{
"categories": [
"organization"
],
- "term": "Balleau Groundwater, Inc",
- "definition": "Balleau Groundwater, Inc"
+ "term": "Friendly Construction, Inc",
+ "definition": "Friendly Construction, Inc"
},
{
"categories": [
"organization"
],
- "term": "CDM Smith",
- "definition": "CDM Smith"
+ "term": "Glorieta Geoscience, Inc",
+ "definition": "Glorieta Geoscience, Inc"
},
{
"categories": [
"organization"
],
- "term": "CH2M Hill",
- "definition": "CH2M Hill"
+ "term": "Golder Associates, Inc",
+ "definition": "Golder Associates, Inc"
},
{
"categories": [
"organization"
],
- "term": "Corbin Consulting, Inc",
- "definition": "Corbin Consulting, Inc"
+ "term": "Hachita MDWCA",
+ "definition": "Hachita MDWCA"
},
{
"categories": [
"organization"
],
- "term": "Chevron",
- "definition": "Chevron"
+ "term": "Hachita Mutual Domestic",
+ "definition": "Hachita Mutual Domestic"
},
{
"categories": [
"organization"
],
- "term": "Daniel B. Stephens & Associates, Inc",
- "definition": "Daniel B. Stephens & Associates, Inc"
+ "term": "Hacienda Del Cerezo",
+ "definition": "Hacienda Del Cerezo"
},
{
"categories": [
"organization"
],
- "term": "EnecoTech",
- "definition": "EnecoTech"
+ "term": "Hathorn's Well Service, Inc",
+ "definition": "Hathorn's Well Service, Inc"
},
{
"categories": [
"organization"
],
- "term": "Faith Engineering, Inc",
- "definition": "Faith Engineering, Inc"
+ "term": "Hefker Vega Ranch",
+ "definition": "Hefker Vega Ranch"
},
{
"categories": [
"organization"
],
- "term": "Foster Well Service, Inc",
- "definition": "Foster Well Service, Inc"
+ "term": "High Nogal Ranch",
+ "definition": "High Nogal Ranch"
},
{
"categories": [
"organization"
],
- "term": "Glorieta Geoscience, Inc",
- "definition": "Glorieta Geoscience, Inc"
+ "term": "Hillsboro MDWCA",
+ "definition": "Hillsboro Mutual Domestic Water Consumer Assn."
},
{
"categories": [
"organization"
],
- "term": "Golder Associates, Inc",
- "definition": "Golder Associates, Inc"
+ "term": "Holloman Air Force Base",
+ "definition": "Holloman Air Force Base"
},
{
"categories": [
"organization"
],
- "term": "Hathorn's Well Service, Inc",
- "definition": "Hathorn's Well Service, Inc"
+ "term": "Hyde Park Estates MDWCA",
+ "definition": "Hyde Park Estates MDWCA"
},
{
"categories": [
@@ -3451,778 +3388,750 @@
"categories": [
"organization"
],
- "term": "Kuckleman Pump Service",
- "definition": "Kuckleman Pump Service"
- },
- {
- "categories": [
- "organization"
- ],
- "term": "Los Golondrinas",
- "definition": "Los Golondrinas"
- },
- {
- "categories": [
- "organization"
- ],
- "term": "Minton Engineers",
- "definition": "Minton Engineers"
- },
- {
- "categories": [
- "organization"
- ],
- "term": "MJDarrconsult, Inc",
- "definition": "MJDarrconsult, Inc"
- },
- {
- "categories": [
- "organization"
- ],
- "term": "Puerta del Canon Ranch",
- "definition": "Puerta del Canon Ranch"
+ "term": "Jornada Experimental Range (JER)",
+ "definition": "Jornada Experimental Range (JER)"
},
{
"categories": [
"organization"
],
- "term": "Rodgers & Company, Inc",
- "definition": "Rodgers & Company, Inc"
+ "term": "K. Schmitt Trust",
+ "definition": "K. Schmitt Trust"
},
{
"categories": [
"organization"
],
- "term": "San Pedro Creek Estates HOA",
- "definition": "San Pedro Creek Estates HOA"
+ "term": "Kuckleman Pump Service",
+ "definition": "Kuckleman Pump Service"
},
{
"categories": [
"organization"
],
- "term": "Statewide Drilling, Inc",
- "definition": "Statewide Drilling, Inc"
+ "term": "La Canada Way HOA",
+ "definition": "La Canada Way HOA"
},
{
"categories": [
"organization"
],
- "term": "Tec Drilling Limited",
- "definition": "Tec Drilling Limited"
+ "term": "La Cienega MDWCA",
+ "definition": "La Cienega MDWCA"
},
{
"categories": [
"organization"
],
- "term": "Tetra Tech, Inc",
- "definition": "Tetra Tech, Inc"
+ "term": "La Joya CWDA",
+ "definition": "La Joya CWDA"
},
{
"categories": [
"organization"
],
- "term": "Thompson Drilling, Inc",
- "definition": "Thompson Drilling, Inc"
+ "term": "La Vista HOA",
+ "definition": "La Vista HOA"
},
{
"categories": [
"organization"
],
- "term": "Witcher & Associates",
- "definition": "Witcher & Associates"
+ "term": "Lake Roberts WUA",
+ "definition": "Lake Roberts Water Assn."
},
{
"categories": [
"organization"
],
- "term": "Zeigler Geologic Consulting, LLC",
- "definition": "Zeigler Geologic Consulting, LLC"
+ "term": "Lamy MDWCA",
+ "definition": "Lamy Mutual Domestic Water Assn."
},
{
"categories": [
"organization"
],
- "term": "Sandia Well Service, Inc",
- "definition": "Sandia Well Service, Inc"
+ "term": "Land Ventures LLC",
+ "definition": "Land Ventures LLC"
},
{
"categories": [
"organization"
],
- "term": "San Marcos Association",
- "definition": "San Marcos Association"
+ "term": "Las Lagunitas",
+ "definition": "Las Lagunitas"
},
{
"categories": [
"organization"
],
- "term": "URS",
- "definition": "URS"
+ "term": "Las Lagunitas HOA",
+ "definition": "Las Lagunitas HOA"
},
{
"categories": [
"organization"
],
- "term": "Vista del Oro",
- "definition": "Vista del Oro"
+ "term": "Lightning Dock Zanskar",
+ "definition": "Lightning Dock Zanskar"
},
{
"categories": [
"organization"
],
- "term": "Abeyta Engineering, Inc",
- "definition": "Abeyta Engineering, Inc"
+ "term": "Living World Ministries",
+ "definition": "Living World Ministries"
},
{
"categories": [
"organization"
],
- "term": "Adobe Ranch",
- "definition": "Adobe Ranch"
+ "term": "Los Atrevidos, Inc",
+ "definition": "Los Atrevidos, Inc"
},
{
"categories": [
"organization"
],
- "term": "Agua Fria Community Water Association",
- "definition": "Agua Fria Community Water Association"
+ "term": "Los Golondrinas",
+ "definition": "Los Golondrinas"
},
{
"categories": [
"organization"
],
- "term": "Apache Gap Ranch",
- "definition": "Apache Gap Ranch"
+ "term": "Los Ojos Mutual Domestic",
+ "definition": "Los Ojos Mutual Domestic"
},
{
"categories": [
"organization"
],
- "term": "Aspendale Mountain Retreat",
- "definition": "Aspendale Mountain Retreat"
+ "term": "Los Prados HOA",
+ "definition": "Los Prados HOA"
},
{
"categories": [
"organization"
],
- "term": "Augustin Plains Ranch LLC",
- "definition": "Augustin Plains Ranch LLC"
+ "term": "Lower Rio Grande Public Water Works Authority",
+ "definition": "Lower Rio Grande Public Water Works Authority"
},
{
"categories": [
"organization"
],
- "term": "B & B Cattle Co",
- "definition": "B & B Cattle Co"
+ "term": "Lybrook MDWCA",
+ "definition": "Lybrook Municipal"
},
{
"categories": [
"organization"
],
- "term": "Berridge Distributing Company",
- "definition": "Berridge Distributing Company"
+ "term": "Madrid Water Co-op",
+ "definition": "Madrid Water Co-op"
},
{
"categories": [
"organization"
],
- "term": "Bishop's Lodge",
- "definition": "Bishop's Lodge"
+ "term": "Malaga MDWCA & SWA",
+ "definition": "Malaga MDWCA & SWA"
},
{
"categories": [
"organization"
],
- "term": "Bonanza Creek Ranch",
- "definition": "Bonanza Creek Ranch"
+ "term": "Mangas Outfitters",
+ "definition": "Mangas Outfitters"
},
{
"categories": [
"organization"
],
- "term": "Bug Scuffle Water Association",
- "definition": "Bug Scuffle Water Association"
+ "term": "McSherry Farms",
+ "definition": "McSherry Farms"
},
{
"categories": [
"organization"
],
- "term": "Wehinahpay Mountain Camp",
- "definition": "Wehinahpay Mountain Camp"
+ "term": "Medina Gravel Pit",
+ "definition": "Medina Gravel Pit"
},
{
"categories": [
"organization"
],
- "term": "Campbell Ranch",
- "definition": "Campbell Ranch"
+ "term": "Mendenhall Trading Co",
+ "definition": "Mendenhall Trading Co"
},
{
"categories": [
"organization"
],
- "term": "Capitol Ford Santa Fe",
- "definition": "Capitol Ford Santa Fe"
+ "term": "Mesa Verde Ranch",
+ "definition": "Mesa Verde Ranch"
},
{
"categories": [
"organization"
],
- "term": "Cemex, Inc",
- "definition": "Cemex, Inc"
+ "term": "Minton Engineers",
+ "definition": "Minton Engineers"
},
{
"categories": [
"organization"
],
- "term": "Cerro Community Center",
- "definition": "Cerro Community Center"
+ "term": "MJDarrconsult, Inc",
+ "definition": "MJDarrconsult, Inc"
},
{
"categories": [
"organization"
],
- "term": "Santa Fe Jewish Center",
- "definition": "Santa Fe Jewish Center"
+ "term": "Naiche Development",
+ "definition": "Naiche Development"
},
{
"categories": [
"organization"
],
- "term": "Chupadero MDWCA",
- "definition": "Chupadero MDWCA"
+ "term": "New Mexico Museum of Natural History",
+ "definition": "New Mexico Museum of Natural History"
},
{
"categories": [
"organization"
],
- "term": "Cielo Lumbre HOA",
- "definition": "Cielo Lumbre HOA"
+ "term": "NM Firefighters Training Academy",
+ "definition": "NM Firefighters Training Academy"
},
{
"categories": [
"organization"
],
- "term": "Circle Cross Ranch",
- "definition": "Circle Cross Ranch"
+ "term": "NMBGMR",
+ "definition": "New Mexico Bureau of Geology and Mineral Resources"
},
{
"categories": [
"organization"
],
- "term": "City of Alamogordo",
- "definition": "City of Alamogordo"
+ "term": "NMDGF",
+ "definition": "New Mexico Department of Game and Fish"
},
{
"categories": [
"organization"
],
- "term": "City of Portales, Public Works Dept.",
- "definition": "City of Portales, Public Works Dept."
+ "term": "NMDOT",
+ "definition": "New Mexico Department of Transportation"
},
{
"categories": [
"organization"
],
- "term": "City of Socorro",
- "definition": "City of Socorro"
+ "term": "NMED",
+ "definition": "New Mexico Environment Department"
},
{
"categories": [
"organization"
],
- "term": "Commonwealth Conservancy",
- "definition": "Commonwealth Conservancy"
+ "term": "NMISC",
+ "definition": "New Mexico Interstate Stream Commission"
},
{
"categories": [
"organization"
],
- "term": "Costilla MDWCA",
- "definition": "Costilla MDWCA"
+ "term": "NMOSE",
+ "definition": "New Mexico Office of the State Engineer"
},
{
"categories": [
"organization"
],
- "term": "Country Club Garden Mobile Home Park",
- "definition": "Country Club Garden Mobile Home Park"
+ "term": "NMRWA",
+ "definition": "New Mexico Rural Water Association"
},
{
"categories": [
"organization"
],
- "term": "Crossroads Cattle Co., Ltd",
- "definition": "Crossroads Cattle Co., Ltd"
+ "term": "NMSA",
+ "definition": "New Mexico Spaceport Authority"
},
{
"categories": [
"organization"
],
- "term": "Double H Ranch",
- "definition": "Double H Ranch"
+ "term": "NMSU",
+ "definition": "New Mexico State University"
},
{
"categories": [
"organization"
],
- "term": "E.A. Meadows East",
- "definition": "E.A. Meadows East"
+ "term": "NMSU College of Agriculture",
+ "definition": "New Mexico State University College of Agriculture"
},
{
"categories": [
"organization"
],
- "term": "El Camino Realty, Inc",
- "definition": "El Camino Realty, Inc"
+ "term": "NMT",
+ "definition": "New Mexico Tech"
},
{
"categories": [
"organization"
],
- "term": "Eldorado Area Water & Sanitation District",
- "definition": "Eldorado Area Water & Sanitation District"
+ "term": "Nogal MDWCA",
+ "definition": "Nogal MDWCA"
},
{
"categories": [
"organization"
],
- "term": "Bourbon Grill at El Gancho",
- "definition": "Bourbon Grill at El Gancho"
+ "term": "Northeastern SWCD",
+ "definition": "Northeastern Soil and Water Conservation District"
},
{
"categories": [
"organization"
],
- "term": "El Prado HOA",
- "definition": "El Prado HOA"
+ "term": "NPS",
+ "definition": "National Park Service"
},
{
"categories": [
"organization"
],
- "term": "El Rancho de las Golondrinas",
- "definition": "El Rancho de las Golondrinas"
+ "term": "NRAO",
+ "definition": "National Radio Astronomy Observatory"
},
{
"categories": [
"organization"
],
- "term": "El Rito Canyon MDWCA",
- "definition": "El Rito Canyon MDWCA"
+ "term": "O Bar O Ranch",
+ "definition": "O Bar O Ranch"
},
{
"categories": [
"organization"
],
- "term": "Encantado Enterprises",
- "definition": "Encantado Enterprises"
+ "term": "Old Road Ranch Pardners Ltd",
+ "definition": "Old Road Ranch Pardners Ltd"
},
{
"categories": [
"organization"
],
- "term": "Estrella Concepts LLC",
- "definition": "Estrella Concepts LLC"
+ "term": "OMI Wastewater Treatment Plant",
+ "definition": "OMI Wastewater Treatment Plant"
},
{
"categories": [
"organization"
],
- "term": "Sixteen Springs Fire Department",
- "definition": "Sixteen Springs Fire Department"
+ "term": "Otero SWCD",
+ "definition": "Otero Soil and Water Conservation District"
},
{
"categories": [
"organization"
],
- "term": "Fire Water Lodge",
- "definition": "Fire Water Lodge"
+ "term": "Otis MDWCA",
+ "definition": "Otis Mutual Domestic"
},
{
"categories": [
"organization"
],
- "term": "Ford County Land & Cattle Company, Inc",
- "definition": "Ford County Land & Cattle Company, Inc"
+ "term": "Our Lady of Guadalupe (OLG)",
+ "definition": "Our Lady of Guadalupe (OLG)"
},
{
"categories": [
"organization"
],
- "term": "Friendly Construction, Inc",
- "definition": "Friendly Construction, Inc"
+ "term": "Peace Tabernacle Church",
+ "definition": "Peace Tabernacle Church"
},
{
"categories": [
"organization"
],
- "term": "Hacienda Del Cerezo",
- "definition": "Hacienda Del Cerezo"
+ "term": "Pecos Trail Inn",
+ "definition": "Pecos Trail Inn"
},
{
"categories": [
"organization"
],
- "term": "Hefker Vega Ranch",
- "definition": "Hefker Vega Ranch"
+ "term": "Pelican Spa",
+ "definition": "Pelican Spa"
},
{
"categories": [
"organization"
],
- "term": "High Nogal Ranch",
- "definition": "High Nogal Ranch"
+ "term": "Pena Blanca Water & Sanitation District",
+ "definition": "Pena Blanca Water & Sanitation District"
},
{
"categories": [
"organization"
],
- "term": "Holloman Air Force Base",
- "definition": "Holloman Air Force Base"
+ "term": "Pendaries Village",
+ "definition": "Pendaries Village"
},
{
"categories": [
"organization"
],
- "term": "Hyde Park Estates MDWCA",
- "definition": "Hyde Park Estates MDWCA"
+ "term": "Pie Town MDWCA",
+ "definition": "Pie Town MDWCA"
},
{
"categories": [
"organization"
],
- "term": "Desert Village RV & Mobile Home Park",
- "definition": "Desert Village RV & Mobile Home Park"
+ "term": "Pinon Ridge WUA",
+ "definition": "Pinon Ridge Water Users Association"
},
{
"categories": [
"organization"
],
- "term": "K. Schmitt Trust",
- "definition": "K. Schmitt Trust"
+ "term": "Pistachio Tree Ranch",
+ "definition": "Pistachio Tree Ranch"
},
{
"categories": [
"organization"
],
- "term": "La Cienega MDWCA",
- "definition": "La Cienega MDWCA"
+ "term": "Placitas Trails Water Co-op",
+ "definition": "Placitas Trails Water Coop"
},
{
"categories": [
"organization"
],
- "term": "La Vista HOA",
- "definition": "La Vista HOA"
+ "term": "PLSS",
+ "definition": "Public Land Survey System"
},
{
"categories": [
"organization"
],
- "term": "Land Ventures LLC",
- "definition": "Land Ventures LLC"
+ "term": "PNM Service Center",
+ "definition": "PNM Service Center"
},
{
"categories": [
"organization"
],
- "term": "Las Lagunitas",
- "definition": "Las Lagunitas"
+ "term": "Puerta del Canon Ranch",
+ "definition": "Puerta del Canon Ranch"
},
{
"categories": [
"organization"
],
- "term": "Las Lagunitas HOA",
- "definition": "Las Lagunitas HOA"
+ "term": "PVACD",
+ "definition": "Pecos Valley Artesian Conservancy District"
},
{
"categories": [
"organization"
],
- "term": "Lightning Dock Zanskar",
- "definition": "Lightning Dock Zanskar"
+ "term": "Quemado Municipal Water & SWA",
+ "definition": "Quemado Mutual Water and Sewage Works Association"
},
{
"categories": [
"organization"
],
- "term": "Living World Ministries",
- "definition": "Living World Ministries"
+ "term": "Rancho Encantado",
+ "definition": "Rancho Encantado"
},
{
"categories": [
"organization"
],
- "term": "Los Atrevidos, Inc",
- "definition": "Los Atrevidos, Inc"
+ "term": "Rancho San Lucas",
+ "definition": "Rancho San Lucas"
},
{
"categories": [
"organization"
],
- "term": "Los Prados HOA",
- "definition": "Los Prados HOA"
+ "term": "Rancho San Marcos",
+ "definition": "Rancho San Marcos"
},
{
"categories": [
"organization"
],
- "term": "Malaga MDWCA & SWA",
- "definition": "Malaga MDWCA & SWA"
+ "term": "Rancho Viejo Partnership",
+ "definition": "Rancho Viejo Partnership"
},
{
"categories": [
"organization"
],
- "term": "Mangas Outfitters",
- "definition": "Mangas Outfitters"
+ "term": "Ranney Ranch",
+ "definition": "Ranney Ranch"
},
{
"categories": [
"organization"
],
- "term": "Medina Gravel Pit",
- "definition": "Medina Gravel Pit"
+ "term": "Reserve Municipality Water Works",
+ "definition": "Reserve Municipality Water Works"
},
{
"categories": [
"organization"
],
- "term": "Mendenhall Trading Co",
- "definition": "Mendenhall Trading Co"
+ "term": "Rio En Medio MDWCA",
+ "definition": "Rio En Medio MDWCA"
},
{
"categories": [
"organization"
],
- "term": "Mesa Verde Ranch",
- "definition": "Mesa Verde Ranch"
+ "term": "Riverbend Hotsprings",
+ "definition": "Riverbend Hotsprings"
},
{
"categories": [
"organization"
],
- "term": "NMDGF",
- "definition": "New Mexico Department of Game and Fish"
+ "term": "Rodgers & Company, Inc",
+ "definition": "Rodgers & Company, Inc"
},
{
"categories": [
"organization"
],
- "term": "NMSU College of Agriculture",
- "definition": "New Mexico State University College of Agriculture"
+ "term": "Roosevelt SWCD",
+ "definition": "Roosevelt Soil & Water Conservation District"
},
{
"categories": [
"organization"
],
- "term": "Naiche Development",
- "definition": "Naiche Development"
+ "term": "San Acacia MDWCA",
+ "definition": "San Acacia MDWCA"
},
{
"categories": [
"organization"
],
- "term": "NRAO",
- "definition": "National Radio Astronomy Observatory"
+ "term": "San Juan Residences",
+ "definition": "San Juan Residences"
},
{
"categories": [
"organization"
],
- "term": "NMSA",
- "definition": "New Mexico Spaceport Authority"
+ "term": "San Marcos Association",
+ "definition": "San Marcos Association"
},
{
"categories": [
"organization"
],
- "term": "Nogal MDWCA",
- "definition": "Nogal MDWCA"
+ "term": "San Pedro Creek Estates HOA",
+ "definition": "San Pedro Creek Estates HOA"
},
{
"categories": [
"organization"
],
- "term": "O Bar O Ranch",
- "definition": "O Bar O Ranch"
+ "term": "Sandia Well Service, Inc",
+ "definition": "Sandia Well Service, Inc"
},
{
"categories": [
"organization"
],
- "term": "OMI Wastewater Treatment Plant",
- "definition": "OMI Wastewater Treatment Plant"
+ "term": "Sangre de Cristo Center",
+ "definition": "Sangre de Cristo Center"
},
{
"categories": [
"organization"
],
- "term": "Old Road Ranch Pardners Ltd",
- "definition": "Old Road Ranch Pardners Ltd"
+ "term": "Sangre de Cristo Estates",
+ "definition": "Sangre de Cristo Estates"
},
{
"categories": [
"organization"
],
- "term": "PNM Service Center",
- "definition": "PNM Service Center"
+ "term": "Santa Ana Pueblo Department of Natural Resources",
+ "definition": "Santa Ana Pueblo Department of Natural Resources"
},
{
"categories": [
"organization"
],
- "term": "Peace Tabernacle Church",
- "definition": "Peace Tabernacle Church"
+ "term": "Santa Clara Water System",
+ "definition": "Santa Clara Water System"
},
{
"categories": [
"organization"
],
- "term": "Pecos Trail Inn",
- "definition": "Pecos Trail Inn"
+ "term": "Santa Fe Community College",
+ "definition": "Santa Fe Community College"
},
{
"categories": [
"organization"
],
- "term": "Pelican Spa",
- "definition": "Pelican Spa"
+ "term": "Santa Fe Conservation Trust",
+ "definition": "Santa Fe Conservation Trust"
},
{
"categories": [
"organization"
],
- "term": "Pistachio Tree Ranch",
- "definition": "Pistachio Tree Ranch"
+ "term": "Santa Fe Downs Resort",
+ "definition": "Santa Fe Downs Resort"
},
{
"categories": [
"organization"
],
- "term": "Rancho Encantado",
- "definition": "Rancho Encantado"
+ "term": "Santa Fe Horse Park",
+ "definition": "Santa Fe Horse Park"
},
{
"categories": [
"organization"
],
- "term": "Rancho San Lucas",
- "definition": "Rancho San Lucas"
+ "term": "Santa Fe Jewish Center",
+ "definition": "Santa Fe Jewish Center"
},
{
"categories": [
"organization"
],
- "term": "Rancho San Marcos",
- "definition": "Rancho San Marcos"
+ "term": "Santa Fe Municipal Airport",
+ "definition": "Santa Fe Municipal Airport"
},
{
"categories": [
"organization"
],
- "term": "Rancho Viejo Partnership",
- "definition": "Rancho Viejo Partnership"
+ "term": "Santa Fe Opera",
+ "definition": "Santa Fe Opera"
},
{
"categories": [
"organization"
],
- "term": "Ranney Ranch",
- "definition": "Ranney Ranch"
+ "term": "Santa Fe Waldorf School",
+ "definition": "Santa Fe Waldorf School"
},
{
"categories": [
"organization"
],
- "term": "Rio En Medio MDWCA",
- "definition": "Rio En Medio MDWCA"
+ "term": "SFC",
+ "definition": "Santa Fe County"
},
{
"categories": [
"organization"
],
- "term": "San Acacia MDWCA",
- "definition": "San Acacia MDWCA"
+ "term": "SFC, Fire Facilities",
+ "definition": "Santa Fe County, Fire Facilities"
},
{
"categories": [
"organization"
],
- "term": "San Juan Residences",
- "definition": "San Juan Residences"
+ "term": "SFC, Santa Fe Animal Shelter",
+ "definition": "Santa Fe County, Santa Fe Animal Shelter"
},
{
"categories": [
"organization"
],
- "term": "Sangre de Cristo Estates",
- "definition": "Sangre de Cristo Estates"
+ "term": "SFC, Utilities Dept.",
+ "definition": "Santa Fe County, Utilities Dept."
},
{
"categories": [
"organization"
],
- "term": "Santa Fe Community College",
- "definition": "Santa Fe Community College"
+ "term": "SFC, Valle Vista Water Utility, Inc.",
+ "definition": "Santa Fe County, Valle Vista Water Utility, Inc."
},
{
"categories": [
"organization"
],
- "term": "Sangre de Cristo Center",
- "definition": "Sangre de Cristo Center"
+ "term": "Shidoni Foundry and Gallery",
+ "definition": "Shidoni Foundry and Gallery"
},
{
"categories": [
"organization"
],
- "term": "Santa Fe Horse Park",
- "definition": "Santa Fe Horse Park"
+ "term": "Sierra Grande Lodge",
+ "definition": "Sierra Grande Lodge"
},
{
"categories": [
"organization"
],
- "term": "Santa Fe Opera",
- "definition": "Santa Fe Opera"
+ "term": "Sierra Vista Retirement Community",
+ "definition": "Sierra Vista Retirement Community"
},
{
"categories": [
"organization"
],
- "term": "Santa Fe Waldorf School",
- "definition": "Santa Fe Waldorf School"
+ "term": "Sile MDWCA",
+ "definition": "Sile Municipal Domestic Water Assn."
},
{
"categories": [
"organization"
],
- "term": "Shidoni Foundry and Gallery",
- "definition": "Shidoni Foundry and Gallery"
+ "term": "Sixteen Springs Fire Department",
+ "definition": "Sixteen Springs Fire Department"
},
{
"categories": [
"organization"
],
- "term": "Sierra Grande Lodge",
- "definition": "Sierra Grande Lodge"
+ "term": "Slash Triangle Ranch",
+ "definition": "Slash Triangle Ranch"
},
{
"categories": [
"organization"
],
- "term": "Sierra Vista Retirement Community",
- "definition": "Sierra Vista Retirement Community"
+ "term": "Smith Ranch LLC",
+ "definition": "Smith Ranch LLC"
},
{
"categories": [
"organization"
],
- "term": "Slash Triangle Ranch",
- "definition": "Slash Triangle Ranch"
+ "term": "SNL",
+ "definition": "Sandia National Laboratories"
},
{
"categories": [
@@ -4252,6 +4161,13 @@
"term": "State of New Mexico",
"definition": "State of New Mexico"
},
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "Statewide Drilling, Inc",
+ "definition": "Statewide Drilling, Inc"
+ },
{
"categories": [
"organization"
@@ -4266,6 +4182,13 @@
"term": "Sun Broadcasting Network",
"definition": "Sun Broadcasting Network"
},
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "Sun Valley Water and Sanitation",
+ "definition": "Sun Valley Water and Sanitation"
+ },
{
"categories": [
"organization"
@@ -4277,8 +4200,15 @@
"categories": [
"organization"
],
- "term": "UNM-Taos",
- "definition": "UNM-Taos"
+ "term": "Taos SWCD",
+ "definition": "Taos Soil and Water Conservation District"
+ },
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "Tec Drilling Limited",
+ "definition": "Tec Drilling Limited"
},
{
"categories": [
@@ -4301,6 +4231,13 @@
"term": "Tesuque MDWCA",
"definition": "Tesuque MDWCA"
},
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "Tetra Tech, Inc",
+ "definition": "Tetra Tech, Inc"
+ },
{
"categories": [
"organization"
@@ -4308,6 +4245,20 @@
"term": "The Great Cloud Zen Center",
"definition": "The Great Cloud Zen Center"
},
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "The Nature Conservancy (TNC)",
+ "definition": "The Nature Conservancy (TNC)"
+ },
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "Thompson Drilling, Inc",
+ "definition": "Thompson Drilling, Inc"
+ },
{
"categories": [
"organization"
@@ -4322,6 +4273,20 @@
"term": "Timberon Water and Sanitation District",
"definition": "Timberon Water and Sanitation District"
},
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "Town of Cerro",
+ "definition": "Town of Cerro"
+ },
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "Town of Estancia",
+ "definition": "Town of Estancia"
+ },
{
"categories": [
"organization"
@@ -4329,6 +4294,13 @@
"term": "Town of Magdalena",
"definition": "Town of Magdalena"
},
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "Town of Questa",
+ "definition": "Town of Questa"
+ },
{
"categories": [
"organization"
@@ -4364,6 +4336,41 @@
"term": "Turquoise Trail Charter School",
"definition": "Turquoise Trail Charter School"
},
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "TWDB",
+ "definition": "Texas Water Development Board"
+ },
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "Tyrone MDWCA",
+ "definition": "Tyrone Mutual Domestic Water Assn."
+ },
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "Uluru Development",
+ "definition": "Uluru Development"
+ },
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "UNM-Taos",
+ "definition": "UNM-Taos"
+ },
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "URS",
+ "definition": "URS"
+ },
{
"categories": [
"organization"
@@ -4371,6 +4378,13 @@
"term": "US Bureau of Indian Affairs, Santa Fe Indian School",
"definition": "US Bureau of Indian Affairs, Santa Fe Indian School"
},
+ {
+ "categories": [
+ "organization"
+ ],
+ "term": "USFS",
+ "definition": "United States Forest Service"
+ },
{
"categories": [
"organization"
@@ -4396,253 +4410,246 @@
"categories": [
"organization"
],
- "term": "USFS, Santa Fe NF, Espanola Ranger District",
- "definition": "USFS, Santa Fe NF, Espanola Ranger District"
- },
- {
- "categories": [
- "organization"
- ],
- "term": "Ute Mountain Farms",
- "definition": "Ute Mountain Farms"
+ "term": "USFS, Kiowa Grasslands",
+ "definition": "USFS, Kiowa Grasslands"
},
{
"categories": [
"organization"
],
- "term": "VA Hospital",
- "definition": "VA Hospital"
+ "term": "USFS, Santa Fe NF, Espanola Ranger District",
+ "definition": "USFS, Santa Fe NF, Espanola Ranger District"
},
{
"categories": [
"organization"
],
- "term": "Velte",
- "definition": "Velte"
+ "term": "USFWS",
+ "definition": "US Fish & Wildlife Service"
},
{
"categories": [
"organization"
],
- "term": "Vereda Serena Property",
- "definition": "Vereda Serena Property"
+ "term": "USGS",
+ "definition": "US Geological Survey"
},
{
"categories": [
"organization"
],
- "term": "Village of Corona",
- "definition": "Village of Corona"
+ "term": "Ute Mountain Farms",
+ "definition": "Ute Mountain Farms"
},
{
"categories": [
"organization"
],
- "term": "Village of Floyd",
- "definition": "Village of Floyd"
+ "term": "VA Hospital",
+ "definition": "VA Hospital"
},
{
"categories": [
"organization"
],
- "term": "Village of Melrose",
- "definition": "Village of Melrose"
+ "term": "Vallecitos HOA",
+ "definition": "Vallecitos HOA"
},
{
"categories": [
"organization"
],
- "term": "Village of Vaughn",
- "definition": "Village of Vaughn"
+ "term": "Velte",
+ "definition": "Velte"
},
{
"categories": [
"organization"
],
- "term": "Vista Land Company",
- "definition": "Vista Land Company"
+ "term": "Vereda Serena Property",
+ "definition": "Vereda Serena Property"
},
{
"categories": [
"organization"
],
- "term": "Vista Redonda MDWCA",
- "definition": "Vista Redonda MDWCA"
+ "term": "Village of Capitan",
+ "definition": "Village of Capitan"
},
{
"categories": [
"organization"
],
- "term": "Vista de Oro de Placitas Water Users Coop",
- "definition": "Vista de Oro de Placitas Water Users Coop"
+ "term": "Village of Corona",
+ "definition": "Village of Corona"
},
{
"categories": [
"organization"
],
- "term": "Walker Ranch",
- "definition": "Walker Ranch"
+ "term": "Village of Floyd",
+ "definition": "Village of Floyd"
},
{
"categories": [
"organization"
],
- "term": "Wild & Woolley Trailer Ranch",
- "definition": "Wild & Woolley Trailer Ranch"
+ "term": "Village of Hope",
+ "definition": "Village of Hope"
},
{
"categories": [
"organization"
],
- "term": "Winter Brothers",
- "definition": "Winter Brothers"
+ "term": "Village of Melrose",
+ "definition": "Village of Melrose"
},
{
"categories": [
"organization"
],
- "term": "Yates Petroleum Corporation",
- "definition": "Yates Petroleum Corporation"
+ "term": "Village of Vaughn",
+ "definition": "Village of Vaughn"
},
{
"categories": [
"organization"
],
- "term": "Zamora Accounting Services",
- "definition": "Zamora Accounting Services"
+ "term": "Village of Willard",
+ "definition": "Village of Willard"
},
{
"categories": [
"organization"
],
- "term": "Agua Sana MWCD",
- "definition": "Agua Sana MWCD"
+ "term": "Vista de Oro de Placitas Water Users Coop",
+ "definition": "Vista de Oro de Placitas Water Users Coop"
},
{
"categories": [
"organization"
],
- "term": "Canada Los Alamos MDWCA",
- "definition": "Canada Los Alamos MDWCA"
+ "term": "Vista del Oro",
+ "definition": "Vista del Oro"
},
{
"categories": [
"organization"
],
- "term": "Canjilon Mutual Domestic Water System",
- "definition": "Canjilon Mutual Domestic Water System"
+ "term": "Vista Land Company",
+ "definition": "Vista Land Company"
},
{
"categories": [
"organization"
],
- "term": "Cebolla Mutual Domestic",
- "definition": "Cebolla Mutual Domestic"
+ "term": "Vista Linda Water Co-op",
+ "definition": "Vista Linda Water Co-op"
},
{
"categories": [
"organization"
],
- "term": "Chihuahuan Desert Rangeland Research Center (CDRRC)",
- "definition": "Chihuahuan Desert Rangeland Research Center (CDRRC)"
+ "term": "Vista Redonda MDWCA",
+ "definition": "Vista Redonda MDWCA"
},
{
"categories": [
"organization"
],
- "term": "East Rio Arriba SWCD",
- "definition": "East Rio Arriba SWCD"
+ "term": "W Spear-bar Ranch",
+ "definition": "W Spear-bar Ranch"
},
{
"categories": [
"organization"
],
- "term": "El Prado Municipal Water",
- "definition": "El Prado Municipal Water"
+ "term": "Walker Ranch",
+ "definition": "Walker Ranch"
},
{
"categories": [
"organization"
],
- "term": "Hachita Mutual Domestic",
- "definition": "Hachita Mutual Domestic"
+ "term": "Wehinahpay Mountain Camp",
+ "definition": "Wehinahpay Mountain Camp"
},
{
"categories": [
"organization"
],
- "term": "Jornada Experimental Range (JER)",
- "definition": "Jornada Experimental Range (JER)"
+ "term": "West Rim MDWUA",
+ "definition": "West Rim MDWUA"
},
{
"categories": [
"organization"
],
- "term": "La Canada Way HOA",
- "definition": "La Canada Way HOA"
+ "term": "White Cliffs MDWUA",
+ "definition": "White Cliffs MDWUA"
},
{
"categories": [
"organization"
],
- "term": "Los Ojos Mutual Domestic",
- "definition": "Los Ojos Mutual Domestic"
+ "term": "White Oaks Pottery",
+ "definition": "White Oaks Pottery"
},
{
"categories": [
"organization"
],
- "term": "The Nature Conservancy (TNC)",
- "definition": "The Nature Conservancy (TNC)"
+ "term": "Wild & Woolley Trailer Ranch",
+ "definition": "Wild & Woolley Trailer Ranch"
},
{
"categories": [
"organization"
],
- "term": "Smith Ranch LLC",
- "definition": "Smith Ranch LLC"
+ "term": "Winter Brothers",
+ "definition": "Winter Brothers"
},
{
"categories": [
"organization"
],
- "term": "Santa Ana Pueblo Department of Natural Resources",
- "definition": "Santa Ana Pueblo Department of Natural Resources"
+ "term": "Witcher & Associates",
+ "definition": "Witcher & Associates"
},
{
"categories": [
"organization"
],
- "term": "Village of Hope",
- "definition": "Village of Hope"
+ "term": "WSP",
+ "definition": "WSP"
},
{
"categories": [
"organization"
],
- "term": "WSP",
- "definition": "WSP"
+ "term": "Yates Petroleum Corporation",
+ "definition": "Yates Petroleum Corporation"
},
{
"categories": [
"organization"
],
- "term": "Zia Pueblo",
- "definition": "Zia Pueblo"
+ "term": "Zamora Accounting Services",
+ "definition": "Zamora Accounting Services"
},
{
"categories": [
"organization"
],
- "term": "Our Lady of Guadalupe (OLG)",
- "definition": "Our Lady of Guadalupe (OLG)"
+ "term": "Zeigler Geologic Consulting, LLC",
+ "definition": "Zeigler Geologic Consulting, LLC"
},
{
"categories": [
"organization"
],
- "term": "PLSS",
- "definition": "Public Land Survey System"
+ "term": "Zia Pueblo",
+ "definition": "Zia Pueblo"
},
{
"categories": [
diff --git a/pyproject.toml b/pyproject.toml
index 9bcad145b..de39d5ae5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -106,7 +106,7 @@ dependencies = [
package = true
[tool.setuptools]
-packages = ["alembic", "cli", "core", "data_migrations", "db", "schemas", "services", "transfers"]
+packages = ["alembic", "cli", "core", "data_migrations", "db", "domain", "schemas", "services", "transfers"]
[project.scripts]
oco = "cli.cli:cli"
From cd75756702d455466624d5b07e411724151b8655 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 10 Aug 2026 15:24:59 +0000
Subject: [PATCH 043/151] build(deps): bump the uv-non-major group with 25
updates (#817)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the uv-non-major group with 25 updates:
| Package | From | To |
| --- | --- | --- |
| [alembic](https://github.com/sqlalchemy/alembic) | `1.18.5` | `1.19.0`
|
| [cachetools](https://github.com/tkem/cachetools) | `7.1.6` | `7.1.7` |
| [cffi](https://github.com/python-cffi/cffi) | `2.1.0` | `2.1.1` |
| [fastapi](https://github.com/fastapi/fastapi) | `0.140.2` | `0.141.1`
|
| [fastapi-pagination](https://github.com/uriyyo/fastapi-pagination) |
`0.15.15` | `0.15.16` |
| [google-api-core](https://github.com/googleapis/google-cloud-python) |
`2.33.0` | `2.34.0` |
| [google-auth](https://github.com/googleapis/google-cloud-python) |
`2.56.2` | `2.56.3` |
| [google-cloud-core](https://github.com/googleapis/google-cloud-python)
| `2.6.0` | `2.6.1` |
|
[google-cloud-storage](https://github.com/googleapis/google-cloud-python)
| `3.13.0` | `3.13.1` |
|
[google-resumable-media](https://github.com/googleapis/google-cloud-python)
| `2.10.0` | `2.10.1` |
|
[googleapis-common-protos](https://github.com/googleapis/google-cloud-python)
| `1.75.0` | `1.75.1` |
| [mako](https://github.com/sqlalchemy/mako) | `1.3.12` | `1.4.1` |
| [packaging](https://github.com/pypa/packaging) | `26.2` | `26.3` |
| [phonenumbers](https://github.com/daviddrysdale/python-phonenumbers) |
`9.0.35` | `9.0.36` |
| [proto-plus](https://github.com/googleapis/google-cloud-python) |
`1.28.2` | `1.28.3` |
| [pygeoapi](https://github.com/geopython/pygeoapi) | `0.23.5` |
`0.24.0` |
| [scramp](https://github.com/tlocke/scramp) | `1.4.15` | `1.4.16` |
| [starlette](https://github.com/Kludex/starlette) | `1.3.1` | `1.4.1` |
| [typer](https://github.com/fastapi/typer) | `0.27.0` | `0.27.1` |
| [utm](https://github.com/Turbo87/utm) | `0.8.1` | `0.9.0` |
| [uvicorn](https://github.com/Kludex/uvicorn) | `0.51.0` | `0.52.1` |
| [annotated-doc](https://github.com/fastapi/annotated-doc) | `0.0.4` |
`0.0.5` |
| [dateparser](https://github.com/scrapinghub/dateparser) | `1.4.1` |
`1.4.2` |
| [filelock](https://github.com/tox-dev/py-filelock) | `3.32.0` |
`3.32.2` |
| [tinydb](https://github.com/msiemens/tinydb) | `4.8.2` | `4.9.0` |
Updates `alembic` from 1.18.5 to 1.19.0
Release notes
Sourced from alembic's
releases .
1.19.0
Released: August 4, 2026
changed
[changed] [installation] Environmental updates:
- Trove classifiers now include Python 3.15 which is now part
of CI
integration
feature
[feature] [autogenerate] Autogenerate now detects
the addition and removal of named CHECK
constraints, as part of the default autogenerate behavior. Detection is
name-based only; a constraint whose name is unchanged is presumed
equivalent regardless of its expression text, as reliably normalizing
SQL expressions across backends for comparison purposes is not generally
feasible. This behavior is implemented as a plugin named
alembic.autogenerate.checkconstraint_byname, and may be
disabled if not
desired by excluding it from the
EnvironmentContext.configure.autogenerate_plugins list.
Pull request courtesy Francois van Kempen.
References: #508
bug
[bug] [commands] Fixed inconsistency where running
stamp or downgrade to base in
offline (--sql) mode would emit a DROP TABLE
alembic_version
statement, while the same operations in online mode never drop the
version
table. Offline mode no longer emits this DROP, matching
online
behavior. The version table continues to be created when it does not
exist;
only the spurious offline-only drop has been removed. Pull request
courtesy imurodl.
References: #1822
Commits
Updates `cachetools` from 7.1.6 to 7.1.7
Changelog
Sourced from cachetools's
changelog .
v7.1.7 (2026-08-01)
Commits
Updates `cffi` from 2.1.0 to 2.1.1
Release notes
Sourced from cffi's
releases .
v2.1.1
What's Changed
Minimize internal Python API usage for interpreter and thread state
sampling where possible. Avoids breaking ABI change in Python >=
3.15.0b4 (python-cffi/cffi#269 ).
Full Changelog : https://github.com/python-cffi/cffi/compare/v2.1.0...v2.1.1
Commits
Updates `fastapi` from 0.140.2 to 0.141.1
Release notes
Sourced from fastapi's
releases .
0.141.1
Fixes
🐛 Fix support for background tasks and headers from dependencies in
app.frontend(). PR #16105
by @tiangolo .
Docs
0.141.0
Features
✨ Add app.frontend(check_dir="auto"), to make
local development more convenient with fastapi dev. PR #16102
by @tiangolo .
0.140.13
Fixes
Docs
0.140.12
Fixes
0.140.11
Fixes
🐛 Fix response_model_* params ignored for non-generator
endpoints with Iterable[..] return type. PR #15093
by @YuriiMotov .
0.140.10
Fixes
Internal
0.140.9
Fixes
🐛 Fix exclude_defaults not propagated to dict keys and
values in jsonable_encoder. PR #16043
by @MBGrao .
... (truncated)
Commits
95f8322
🔖 Release version 0.141.1 (#16106 )
f137944
📝 Update release notes
d623544
🐛 Fix support for background tasks and headers from dependencies in
`app.fron...
1d211b9
📝 Update release notes
8a1f876
📝 Document FASTAPI_ENV in FastAPI CLI guide (#16104 )
c7e7b65
🔖 Release version 0.141.0 (#16103 )
6bceb84
📝 Update release notes
5429fed
✨ Add app.frontend(check_dir="auto"), to make
local development more conven...
628663f
🔖 Release version 0.140.13 (#16096 )
0b54fd0
📝 Update release notes
Additional commits viewable in compare
view
Updates `fastapi-pagination` from 0.15.15 to 0.15.16
Release notes
Sourced from fastapi-pagination's
releases .
0.15.16
What's Changed
Support new fastapi version 0.140.5+. #1990
Full Changelog : https://github.com/uriyyo/fastapi-pagination/compare/0.15.15...0.15.16
Commits
Updates `google-api-core` from 2.33.0 to 2.34.0
Release notes
Sourced from google-api-core's
releases .
google-api-core: v2.34.0
2.34.0
(2026-08-06)
Features
Bug Fixes
api-core: use truthiness check in setup_request_id
to support proto-plus messages (#18000 )
(ad8f93c )
bump grpcio to 1.59.0; require Python 3.10+ (#17351 )
(a53487a )
deduplicate x-goog-api-client headers (#17616 )
(6167e41 )
require Protobuf 6.33.5+ (#17743 )
(d267342 )
Commits
905bfe3
chore: release main (#17832 )
ad8f93c
fix(api-core): use truthiness check in setup_request_id to support
proto-plus...
06dd2c0
chore: bump google-api-core to 2.28.0 (#18003 )
2619725
chore: remove bigtable and sqlalchemy-spanner from bulk release due to
faili...
56b6bd2
fix(ci): skip loaded lines calculation on iterations after the first (#17790 )
08f21a6
fix(proto-plus): add context to TypeErrors during message manipulation
(#17682 )
32fd479
tests(spanner): avoid table name collisions in tests (#18001 )
2207ca6
feat: add pandas-gbq capability helper (#17957 )
6167e41
fix: deduplicate x-goog-api-client headers (#17616 )
71bc622
docs: add connector libraries overview table to package README (#17939 )
Additional commits viewable in compare
view
Updates `google-auth` from 2.56.2 to 2.56.3
Release notes
Sourced from google-auth's
releases .
google-auth: v2.56.3
2.56.3
(2026-08-06)
Bug Fixes
auth: avoid creating mTLS SSL context for custom
async transports (#17825 )
(fbe33f9 ),
refs #17622
auth: only trigger mTLS certificate rotation on
mTLS endpoints (#17928 )
(f7b49ea )
auth: properly extract stdout from gnubby webauthn
plugin failures (#17885 )
(744e826 )
deduplicate x-goog-api-client headers (#17616 )
(6167e41 )
oauth2: avoid redundant JWKS network fetches (#17891 )
(de53298 )
Performance Improvements
auth: use generator expression in any() to allow
short-circuiting (735e565 )
auth: use generator expression in any() to allow
short-circuiting (#17937 )
(735e565 )
Commits
905bfe3
chore: release main (#17832 )
ad8f93c
fix(api-core): use truthiness check in setup_request_id to support
proto-plus...
06dd2c0
chore: bump google-api-core to 2.28.0 (#18003 )
08f21a6
fix(proto-plus): add context to TypeErrors during message manipulation
(#17682 )
32fd479
tests(spanner): avoid table name collisions in tests (#18001 )
2207ca6
feat: add pandas-gbq capability helper (#17957 )
6167e41
fix: deduplicate x-goog-api-client headers (#17616 )
71bc622
docs: add connector libraries overview table to package README (#17939 )
1b5c48b
fix(bigframes): fix field name typos for ai.generate* functions (#17983 )
f64ada2
fix: avoid retaining routing parameter instances in cache (#17961 )
Additional commits viewable in compare
view
Updates `google-cloud-core` from 2.6.0 to 2.6.1
Release notes
Sourced from google-cloud-core's
releases .
google-cloud-core: v2.6.1
2.6.1
(2026-08-06)
Bug Fixes
Changelog
Sourced from google-cloud-core's
changelog .
Changelog
PyPI
History
3.15.0
(2026-06-02)
Features
3.14.0
(2026-04-02)
Features
3.13.0
(2026-03-26)
Features
Bug Fixes
3.12.0
(2026-03-23)
Features
3.11.0
(2026-03-05)
Features
3.10.0
(2026-02-12)
... (truncated)
Commits
905bfe3
chore: release main (#17832 )
ad8f93c
fix(api-core): use truthiness check in setup_request_id to support
proto-plus...
06dd2c0
chore: bump google-api-core to 2.28.0 (#18003 )
08f21a6
fix(proto-plus): add context to TypeErrors during message manipulation
(#17682 )
32fd479
tests(spanner): avoid table name collisions in tests (#18001 )
2207ca6
feat: add pandas-gbq capability helper (#17957 )
6167e41
fix: deduplicate x-goog-api-client headers (#17616 )
71bc622
docs: add connector libraries overview table to package README (#17939 )
1b5c48b
fix(bigframes): fix field name typos for ai.generate* functions (#17983 )
f64ada2
fix: avoid retaining routing parameter instances in cache (#17961 )
Additional commits viewable in compare
view
Updates `google-cloud-storage` from 3.13.0 to 3.13.1
Release notes
Sourced from google-cloud-storage's
releases .
google-cloud-storage: v3.13.1
3.13.1
(2026-08-06)
Bug Fixes
Changelog
Sourced from google-cloud-storage's
changelog .
Changelog
PyPI
History
3.15.0
(2026-06-02)
Features
3.14.0
(2026-04-02)
Features
Commits
905bfe3
chore: release main (#17832 )
ad8f93c
fix(api-core): use truthiness check in setup_request_id to support
proto-plus...
06dd2c0
chore: bump google-api-core to 2.28.0 (#18003 )
08f21a6
fix(proto-plus): add context to TypeErrors during message manipulation
(#17682 )
32fd479
tests(spanner): avoid table name collisions in tests (#18001 )
2207ca6
feat: add pandas-gbq capability helper (#17957 )
6167e41
fix: deduplicate x-goog-api-client headers (#17616 )
71bc622
docs: add connector libraries overview table to package README (#17939 )
1b5c48b
fix(bigframes): fix field name typos for ai.generate* functions (#17983 )
f64ada2
fix: avoid retaining routing parameter instances in cache (#17961 )
Additional commits viewable in compare
view
Updates `google-resumable-media` from 2.10.0 to 2.10.1
Release notes
Sourced from google-resumable-media's
releases .
google-resumable-media: v2.10.1
2.10.1
(2026-08-06)
Bug Fixes
Changelog
Sourced from google-resumable-media's
changelog .
Changelog
PyPI
History
3.15.0
(2026-06-02)
Features
3.14.0
(2026-04-02)
Features
3.13.0
(2026-03-26)
Features
Bug Fixes
3.12.0
(2026-03-23)
Features
3.11.0
(2026-03-05)
Features
3.10.0
(2026-02-12)
... (truncated)
Commits
905bfe3
chore: release main (#17832 )
ad8f93c
fix(api-core): use truthiness check in setup_request_id to support
proto-plus...
06dd2c0
chore: bump google-api-core to 2.28.0 (#18003 )
08f21a6
fix(proto-plus): add context to TypeErrors during message manipulation
(#17682 )
32fd479
tests(spanner): avoid table name collisions in tests (#18001 )
2207ca6
feat: add pandas-gbq capability helper (#17957 )
6167e41
fix: deduplicate x-goog-api-client headers (#17616 )
71bc622
docs: add connector libraries overview table to package README (#17939 )
1b5c48b
fix(bigframes): fix field name typos for ai.generate* functions (#17983 )
f64ada2
fix: avoid retaining routing parameter instances in cache (#17961 )
Additional commits viewable in compare
view
Updates `googleapis-common-protos` from 1.75.0 to 1.75.1
Release notes
Sourced from googleapis-common-protos's
releases .
googleapis-common-protos: v1.75.1
1.75.1
(2026-08-06)
Bug Fixes
Commits
905bfe3
chore: release main (#17832 )
ad8f93c
fix(api-core): use truthiness check in setup_request_id to support
proto-plus...
06dd2c0
chore: bump google-api-core to 2.28.0 (#18003 )
08f21a6
fix(proto-plus): add context to TypeErrors during message manipulation
(#17682 )
32fd479
tests(spanner): avoid table name collisions in tests (#18001 )
2207ca6
feat: add pandas-gbq capability helper (#17957 )
6167e41
fix: deduplicate x-goog-api-client headers (#17616 )
71bc622
docs: add connector libraries overview table to package README (#17939 )
1b5c48b
fix(bigframes): fix field name typos for ai.generate* functions (#17983 )
f64ada2
fix: avoid retaining routing parameter instances in cache (#17961 )
Additional commits viewable in compare
view
Updates `mako` from 1.3.12 to 1.4.1
Release notes
Sourced from mako's
releases .
1.4.1
Released: Wed Aug 5 2026
bug
[bug] [installation] Fixed issue in the 1.4.0
packaging where the repository's internal
tools/ directory was detected by setuptools package
discovery and
installed as a top-level tools package into site-packages,
shadowing
unrelated tools packages belonging to other applications.
Package
discovery is now limited to the mako package
explicitly.
References: #438
1.4.0
Released: Tue Aug 4 2026
changed
[changed] [examples] The examples/bench
folder has been removed as it used mostly
long-obsolete template engines. The
examples/wsgi/run_wsgi.py example
has been updated to remove the use of the removed-in-Python-3.13
cgi
module, and to be runnable as a module from the project root.
[changed] [installation] Minimum MarkupSafe
dependency version bumped from 0.9.2 to 2.0.
[changed] [tests] The test suite now runs via nox.
The old tox.ini remains however nox will
be the only system that's maintained.
[changed] [installation] Project metadata has been
migrated to PEP 621
pyproject.toml-based
configuration. setup.cfg remains only for the
[mako_testing]
section used by Mako's own test suite. The build requirements
now set the minimum setuptools version at 77.0.0 in order to build Mako
from source.
[changed] [installation] Minimum Python version is
now 3.10. Mako 1.4.0 has been tested up through
Python 3.15.0b4.
bug
[bug] [ext] The minimum Lingua version supported by
LinguaMakoExtractor is
now 4.16. The test suite had continued to pin Lingua below 4 long after
the extractor itself was repaired to work with Lingua 4 in version
1.2.0,
with the result that the plugin was no longer covered by tests at all;
the
pinned version additionally imports pkg_resources at
startup, which is
not present in current setuptools releases and left the package
... (truncated)
Commits
Updates `packaging` from 26.2 to 26.3
Release notes
Sourced from packaging's
releases .
26.3
What's Changed
Features
Add a public VersionRange API and
SpecifierSet.to_range(), representing the versions a
specifier set accepts as an interval set that supports intersection,
union, difference, complement, set relations, membership tests, and
filtering. VersionRange.to_specifier_set() converts a range
back to a SpecifierSet where a PEP 440 form exists. (#1267 ,
#1270 ,
#1298 )
PEP 808: accept Metadata-Version: 2.6. (#1194 )
Add a limit argument to parse_tag() for
compressed tag sets. (#1220 )
Add a prefer_sdist_predicate argument to
Pylock.select() to prefer source distributions over wheels
for selected packages. (#1334 )
Add pure_python_tags() to generate the pure-Python tags
for a Python version without touching the running platform. (#1346 )
Add SpecifierSet.is_subset(),
SpecifierSet.is_superset(), and
SpecifierSet.is_disjoint(), which compare the versions two
specifier sets accept. (#1313 )
Behavior adaptations
Drop support for Python 3.8; packaging now requires Python 3.9 or
later. (#1157 )
Prefer native linux_* platform tags over
manylinux and musllinux tags on Linux. (#160 )
Fixes for versions and specifiers
Raise InvalidVersion instead of TypeError
when Version is given a non-string. (#1319 )
Raise InvalidVersion for non-string pre-release letters
passed to Version.from_parts. (#1241 )
Fix an AttributeError when hashing internally trimmed
versions. (#1242 )
Fix SpecifierSet.is_unsatisfiable for post-release
boundary intersections. (#1257 )
Fixes for requirements and markers
Make Requirement.__hash__ consistent with
__eq__ for trailing-zero-equivalent specifiers (e.g.
foo==1.0.0 and foo==1.0.0.0), so equal
requirements hash equal and deduplicate in sets and dicts. (#1232 )
Normalize requested extra names before comparing or hashing
requirements. (#644 )
Preserve a Requirement's specifier
prereleases override across a pickle round trip. (#1204 )
Raise InvalidRequirement instead of
InvalidSpecifier when a requirement contains an invalid
specifier. (#1332 )
Clarify the error for post-release prefix wildcards like
==1.0.post1.*. (#1299 )
Preserve quoting semantics when serializing marker values, so
round-tripped markers parse back to the same marker. (#1213 )
Keep the parentheses of a nested group when serializing markers. (#1316 )
Normalize extra and dependency_groups
values in nested markers at parse time. (#1246 ,
#1310 )
Raise UndefinedComparison when a set-valued variable
like extras is used outside the membership form. (#1265 )
Raise UndefinedEnvironmentName (a KeyError
subclass) for missing environment keys during marker evaluation. (#1276 )
Wrap malformed string literal errors in InvalidMarker /
InvalidRequirement instead of leaking a low-level error.
(#1249 )
Reject requirements and markers with a trailing line break. (#1345 )
Fixes for metadata and licenses
Collect all from_email validation errors into one
ExceptionGroup instead of raising the first. (#1268 )
Accept the UTF-8 charset case-insensitively in email payloads. (#1330 )
Reject malformed Description-Content-Type values. (#1329 )
Don't rewrite user values that contain {field}
placeholders in error messages. (#1327 )
Route multipart email payloads to unparsed instead of
asserting. (#1247 )
Make InvalidMetadata and
CyclicDependencyGroup picklable. (#1328 )
Fold every line boundary str.splitlines recognizes when
writing a header with RFC822Message. (#1356 )
... (truncated)
Changelog
Sourced from packaging's
changelog .
26.3 - 2026-08-03
Features:
Add a public :class:~packaging.ranges.VersionRange API
and
:meth:SpecifierSet.to_range()
<packaging.specifiers.SpecifierSet.to_range>,
representing the versions a specifier set accepts as an interval set
that
supports intersection, union, difference, complement, set relations,
membership tests, and filtering.
:meth:~packaging.ranges.VersionRange.to_specifier_set
converts a range back
to a :class:~packaging.specifiers.SpecifierSet where a PEP
440 form exists.
(:pull:1267, :pull:1270,
:pull:1298)
PEP 808: accept Metadata-Version: 2.6.
(:pull:1194)
Add a limit argument to parse_tag() for
compressed tag sets.
(:issue:1220)
Add a prefer_sdist_predicate argument to
Pylock.select() to prefer
source distributions over wheels for selected packages.
(:pull:1334)
Add :func:~packaging.tags.pure_python_tags to generate
the pure-Python
tags for a Python version without touching the running platform.
(:pull:1346)
Add :meth:SpecifierSet.is_subset()
<packaging.specifiers.SpecifierSet.is_subset>,
:meth:~packaging.specifiers.SpecifierSet.is_superset,
and :meth:~packaging.specifiers.SpecifierSet.is_disjoint,
which compare the
versions two specifier sets accept. (:pull:1313)
Behavior adaptations:
Drop support for Python 3.8; packaging now requires Python 3.9 or
later.
(:pull:1157)
Prefer native linux_* platform tags over
manylinux and musllinux
tags on Linux. (:issue:160)
Fixes for versions and specifiers:
Raise InvalidVersion instead of TypeError
when Version is given a
non-string. (:pull:1319)
Raise InvalidVersion for non-string pre-release letters
passed to
Version.from_parts. (:pull:1241)
Fix an AttributeError when hashing internally trimmed
versions.
(:pull:1242)
Fix SpecifierSet.is_unsatisfiable for post-release
boundary
intersections. (:pull:1257)
Fixes for requirements and markers:
Make Requirement.__hash__ consistent with
__eq__ for
trailing-zero-equivalent specifiers (e.g. foo==1.0.0 and
foo==1.0.0.0), so equal requirements hash equal and
deduplicate in
sets and dicts. (:pull:1232)
</tr></table>
... (truncated)
Commits
929fd4b
Bump for release
f300ebf
chore(deps): bump the pre-commit group with 5 updates (#1357 )
f91d975
ci(downstream): bump hatchling to 1.31.0 and fix its pytest rootdir (#1361 )
b1a7124
chore(deps): bump the github-actions group with 7 updates (#1358 )
2d873eb
fix(metadata): fold every line boundary when writing headers (#1356 )
413d006
docs: changelog for 26.3 (#1343 )
4eb0753
docs(metadata): explain selective field validation (#1342 )
77e9ed4
feat(tags): add pure Python tag generator (#1346 )
7cea5e8
ci: drop 3.13t on Windows (3.13.14t may fail to build, run takes 9
minutes) (...
45a8b34
docs: add missing versionadded/versionchanged directives (=2.9.12",
"pyasn1==0.6.4",
@@ -71,7 +71,7 @@ dependencies = [
"pydantic-core==2.41.5",
"pygments==2.20.0",
"pyjwt==2.13.0",
- "pygeoapi==0.23.5",
+ "pygeoapi==0.24.0",
"pyproj==3.7.2",
"pyshp==2.3.1",
"python-dateutil==2.9.0.post0",
@@ -80,7 +80,7 @@ dependencies = [
"pytz==2026.3.post1",
"requests==2.34.2",
"rsa==4.9.1",
- "scramp==1.4.15",
+ "scramp==1.4.16",
"sentry-sdk[fastapi]==2.66.1",
"shapely==2.1.2",
"six==1.17.0",
@@ -90,14 +90,14 @@ dependencies = [
"sqlalchemy-searchable==2.1.0",
"sqlalchemy-utils==0.42.1",
"sqlparse>=0.5.5",
- "starlette==1.3.1",
- "typer==0.27.0",
+ "starlette==1.4.1",
+ "typer==0.27.1",
"typing-extensions==4.16.0",
"typing-inspection==0.4.2",
"tzdata==2026.3",
"urllib3==2.7.0",
- "utm==0.8.1",
- "uvicorn==0.51.0",
+ "utm==0.9.0",
+ "uvicorn==0.52.1",
"yarl==1.24.5",
"pymssql>=2.3.13",
]
diff --git a/requirements.txt b/requirements.txt
index 2f1437493..d7f6222ef 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -149,13 +149,13 @@ aiosqlite==0.22.1 \
--hash=sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650 \
--hash=sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb
# via ocotilloapi
-alembic==1.18.5 \
- --hash=sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc \
- --hash=sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e
+alembic==1.19.0 \
+ --hash=sha256:6487c612fc719dcfa22b17d2dd5b2b458929641e6aa2f0b65b135727f5e6d501 \
+ --hash=sha256:cf839d3849116aab3cc047e09c6968b9bd6b2fde61b6bb7c1e97352fe5503580
# via ocotilloapi
-annotated-doc==0.0.4 \
- --hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \
- --hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4
+annotated-doc==0.0.5 \
+ --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
+ --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
# via
# fastapi
# typer
@@ -285,9 +285,9 @@ blinker==1.9.0 \
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
# via flask
-cachetools==7.1.6 \
- --hash=sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096 \
- --hash=sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1
+cachetools==7.1.7 \
+ --hash=sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50 \
+ --hash=sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0
# via ocotilloapi
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
@@ -300,107 +300,107 @@ certifi==2026.7.22 \
# rasterio
# requests
# sentry-sdk
-cffi==2.1.0 \
- --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
- --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
- --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
- --hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
- --hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
- --hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
- --hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
- --hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
- --hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
- --hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
- --hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
- --hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
- --hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
- --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
- --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
- --hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
- --hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
- --hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
- --hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
- --hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
- --hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
- --hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
- --hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
- --hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
- --hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
- --hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
- --hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
- --hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
- --hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
- --hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
- --hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
- --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
- --hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
- --hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
- --hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
- --hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
- --hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
- --hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
- --hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
- --hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
- --hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
- --hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
- --hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
- --hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
- --hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
- --hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
- --hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
- --hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
- --hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
- --hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
- --hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
- --hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
- --hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
- --hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
- --hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
- --hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
- --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
- --hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
- --hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
- --hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
- --hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
- --hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
- --hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
- --hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
- --hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
- --hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
- --hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
- --hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
- --hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
- --hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
- --hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
- --hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
- --hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
- --hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
- --hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
- --hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
- --hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
- --hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
- --hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
- --hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
- --hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
- --hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
- --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
- --hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
- --hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
- --hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
- --hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
- --hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
- --hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
- --hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
- --hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
- --hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
- --hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
- --hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
- --hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
- --hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
- --hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
- --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
- --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
- --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
+cffi==2.1.1 \
+ --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \
+ --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \
+ --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \
+ --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \
+ --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \
+ --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \
+ --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \
+ --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \
+ --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \
+ --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \
+ --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \
+ --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \
+ --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \
+ --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \
+ --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \
+ --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \
+ --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \
+ --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \
+ --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \
+ --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \
+ --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \
+ --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \
+ --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \
+ --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \
+ --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \
+ --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \
+ --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \
+ --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \
+ --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \
+ --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \
+ --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \
+ --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \
+ --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \
+ --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \
+ --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \
+ --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \
+ --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \
+ --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \
+ --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \
+ --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \
+ --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \
+ --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \
+ --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \
+ --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \
+ --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \
+ --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \
+ --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \
+ --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \
+ --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \
+ --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \
+ --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \
+ --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \
+ --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \
+ --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \
+ --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \
+ --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \
+ --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \
+ --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \
+ --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \
+ --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \
+ --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \
+ --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \
+ --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \
+ --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \
+ --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \
+ --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \
+ --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \
+ --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \
+ --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \
+ --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \
+ --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \
+ --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \
+ --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \
+ --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \
+ --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \
+ --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \
+ --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \
+ --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \
+ --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \
+ --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \
+ --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \
+ --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \
+ --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \
+ --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \
+ --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \
+ --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \
+ --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \
+ --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \
+ --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \
+ --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \
+ --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \
+ --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \
+ --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \
+ --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \
+ --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \
+ --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \
+ --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \
+ --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \
+ --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \
+ --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264
# via
# cryptography
# ocotilloapi
@@ -579,9 +579,9 @@ cryptography==50.0.0 \
# google-auth
# joserfc
# ocotilloapi
-dateparser==1.4.1 \
- --hash=sha256:f25d4e051a84be27a35bd297e3e1dc59ff78373701b89be352ba80372d22d0d0 \
- --hash=sha256:f265df13c0380e2e07543ba74b67c0681aaa1096981ffcd35227e1aa0cb81c7c
+dateparser==1.4.2 \
+ --hash=sha256:752f3d49d477cf7f60a7a9c8bcb19c882496ede0e377d5a3d80014cdfeca7050 \
+ --hash=sha256:bed2a3fd9bad8f2fb2d72b57748bada260b3a9349a264c22ffc23c3249d7049a
# via pygeofilter
dnspython==2.8.0 \
--hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \
@@ -601,21 +601,21 @@ email-validator==2.3.0 \
--hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \
--hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426
# via ocotilloapi
-fastapi==0.140.2 \
- --hash=sha256:5f64faeb12339d783510db0498da660539195ac7b5bdf69c0fc558f4580a9724 \
- --hash=sha256:944336ef298148dfd97478638567b223d929aba8717ad8be73ae962a62ecd61d
+fastapi==0.141.1 \
+ --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
+ --hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
# via
# apitally
# fastapi-pagination
# ocotilloapi
# sentry-sdk
-fastapi-pagination==0.15.15 \
- --hash=sha256:d6e9e4bc4d6e20709dcabc11b16056cd5cd184c995ee214b0190f6b81426fa0c \
- --hash=sha256:dc828d7cd15614c650c284bd2c3a98a8a2d9ce340508be3970dc8986908a02aa
+fastapi-pagination==0.15.16 \
+ --hash=sha256:739a4e904729dc01e03ce95e260ed9be050d48a3659be8463e5c4fcdd0cf25b0 \
+ --hash=sha256:86dc73620812d47c297a7b8baca4eba2bac5d3f1d73aa75dc6e3bb12c0b803f7
# via ocotilloapi
-filelock==3.32.0 \
- --hash=sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402 \
- --hash=sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3
+filelock==3.32.2 \
+ --hash=sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82 \
+ --hash=sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8
# via pygeoapi
flask==3.1.3 \
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
@@ -696,31 +696,31 @@ geoalchemy2==0.20.0 \
--hash=sha256:1489a1d106519542a79c97cd0b4c537d80462c353610ebc2429cf2c43daac717 \
--hash=sha256:450f427f4bc3cf2d5ddee0af3763aed0f3eea2384e7c9a99798d8f1508279322
# via ocotilloapi
-google-api-core==2.33.0 \
- --hash=sha256:3a36bcc3e319783f4c97da41f6f45ea6ffcaa55848e341de16e09cb70243c2bb \
- --hash=sha256:a2e22a0c1d0f03eafff1858b38cf46f832d5902b0c052235bf0ab8402929fbdc
+google-api-core==2.34.0 \
+ --hash=sha256:98a779fe72de956eb1c9c2f47ff4c4432a668ece1a002ec38bed07ec2698ae59 \
+ --hash=sha256:cdf9c67e7ca2402d86ccbfde5f2503fc83e3cc3f58cc78456ae96cad24a6d2de
# via
# google-cloud-core
# google-cloud-storage
# ocotilloapi
-google-auth==2.56.2 \
- --hash=sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6 \
- --hash=sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051
+google-auth==2.56.3 \
+ --hash=sha256:40e229fc901f0a305b553050e5fce562d509bee0435be053abfa91582b51b90c \
+ --hash=sha256:8ec438808f813ad034535000261eed1067475d229d05bbf4216e78c3f2362e53
# via
# cloud-sql-python-connector
# google-api-core
# google-cloud-core
# google-cloud-storage
# ocotilloapi
-google-cloud-core==2.6.0 \
- --hash=sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e \
- --hash=sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83
+google-cloud-core==2.6.1 \
+ --hash=sha256:1e044b131f2ae097b92312fa195164b0aeb6dc6a88e00231e1210516314c420c \
+ --hash=sha256:2682a8a4474a32f56292fb4bca7fa7e4fb0b4af958f6abfe4bca8d195747fd45
# via
# google-cloud-storage
# ocotilloapi
-google-cloud-storage==3.13.0 \
- --hash=sha256:648af3ef8a6acc674e1359d3c920c67eb89a7a5ab66b336bd3ac43fed6b5ab84 \
- --hash=sha256:d11d8706ea1520fba0f21043bcb7897caf7015d76ce1ad9a4f60237e4d7a9f6c
+google-cloud-storage==3.13.1 \
+ --hash=sha256:98208de6c21e85cecd3eb44551894efff33d98365500e178867d4305854a770a \
+ --hash=sha256:a80bf8cac2794808aa61c50c5f769ecbbe2d10331bacd0d69d30e59b14b346b2
# via ocotilloapi
google-crc32c==1.8.0 \
--hash=sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa \
@@ -738,15 +738,15 @@ google-crc32c==1.8.0 \
# google-cloud-storage
# google-resumable-media
# ocotilloapi
-google-resumable-media==2.10.0 \
- --hash=sha256:88152884bee37b2bf36a0ab81ad8c7fd12212c9803dd981d77c1b35b02d34e7c \
- --hash=sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee
+google-resumable-media==2.10.1 \
+ --hash=sha256:224975032ddb73f7ed9e2f0f4cc08ed1b06874c52d48cc8533e3eb72980b21a0 \
+ --hash=sha256:4e2cbc704207ddc09f23b1f18e8ef4a4ccbfe0f1768b370e5c969704adbd0a1c
# via
# google-cloud-storage
# ocotilloapi
-googleapis-common-protos==1.75.0 \
- --hash=sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd \
- --hash=sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed
+googleapis-common-protos==1.75.1 \
+ --hash=sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79 \
+ --hash=sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071
# via
# google-api-core
# ocotilloapi
@@ -904,9 +904,9 @@ lark==1.3.1 \
--hash=sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905 \
--hash=sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12
# via pygeofilter
-mako==1.3.12 \
- --hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \
- --hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a
+mako==1.4.1 \
+ --hash=sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617 \
+ --hash=sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27
# via
# alembic
# ocotilloapi
@@ -1114,9 +1114,9 @@ opentelemetry-semantic-conventions==0.65b0 \
--hash=sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb \
--hash=sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60
# via opentelemetry-sdk
-packaging==26.2 \
- --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
- --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
+packaging==26.3 \
+ --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
+ --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via
# geoalchemy2
# gunicorn
@@ -1145,9 +1145,9 @@ pg8000==1.31.5 \
--hash=sha256:0af2c1926b153307639868d2ee5cef6cd3a7d07448e12736989b10e1d491e201 \
--hash=sha256:46ebb03be52b7a77c03c725c79da2ca281d6e8f59577ca66b17c9009618cae78
# via ocotilloapi
-phonenumbers==9.0.35 \
- --hash=sha256:57ef9787ddf2bc8cc0906d5c876d43fcd65fa25e7330d00b9f2ba8528870b72a \
- --hash=sha256:b19d97e8c448ccfd8d646888d2a65635372f75b9916006cc0a2f809b531baaea
+phonenumbers==9.0.36 \
+ --hash=sha256:60ca2a6870d2532d6e960105790e85e9d69270ede81746d10cf57f5e437b93ec \
+ --hash=sha256:c16a5b95178345ec2df1954578a4ac09e937443b2ee992dd7b15a8e8c81f4acf
# via ocotilloapi
pillow==12.3.0 \
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
@@ -1317,9 +1317,9 @@ propcache==0.5.2 \
# aiohttp
# ocotilloapi
# yarl
-proto-plus==1.28.2 \
- --hash=sha256:26d843eb99c1e32fdf1d20ff0faae56607f7748fe774acf9ecd5cfe6c6472501 \
- --hash=sha256:b874236fcac2358f601e4330bcb76cb8b89c851303ccf4078408b3d4774d1c52
+proto-plus==1.28.3 \
+ --hash=sha256:5f91b30dafa6bb38d432c5557a6ee1d35ffd40b4b1e0e3ca27260448560b91d9 \
+ --hash=sha256:dc76880b8ee951cca002098574376cf71e055f9f16d9ba6570fb8a06f726d281
# via
# google-api-core
# ocotilloapi
@@ -1460,9 +1460,9 @@ pydantic-core==2.41.5 \
# via
# ocotilloapi
# pydantic
-pygeoapi==0.23.5 \
- --hash=sha256:66ec6c466f00a2ec4af77b886bc4b046d70a2fde4cdf21021e551231db955e19 \
- --hash=sha256:9f1456738a8851c582f7336159dfaaa96ca9704b9c554c0cc59d699042314359
+pygeoapi==0.24.0 \
+ --hash=sha256:00cf5c88776284780bb8bcc82f4f48c52d25ddfefc9ba1a65a8f3f811670a0a3 \
+ --hash=sha256:0ca8063121c19b86c90e896ed72758ce942be6c4a232062cc919ff7a8af36435
# via ocotilloapi
pygeofilter==0.4.0 \
--hash=sha256:cbb4a5f14af0b87e4f0c0c81c659ff64e44351c98e9f61d36af515d896fa8a05 \
@@ -1887,9 +1887,9 @@ rsa==4.9.1 \
# via
# ocotilloapi
# python-jose
-scramp==1.4.15 \
- --hash=sha256:9d6102948d9005e3802384a328429dfd67d691a65791007c354ff89895857396 \
- --hash=sha256:d25cdd3dbc493773647bccb93e4e85bbff0c091141ec8ff8d61bad1e3638082d
+scramp==1.4.16 \
+ --hash=sha256:836bcecdc8843af76b35e11adbe818e3d7934d0d25aa48fd2e8cde362a2c6a9b \
+ --hash=sha256:9e6148fca008ab6c6ce84658aeefd03d5d7f269d32fe08d07e134dae089e937a
# via
# ocotilloapi
# pg8000
@@ -1999,9 +1999,9 @@ sqlparse==0.5.5 \
--hash=sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba \
--hash=sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e
# via ocotilloapi
-starlette==1.3.1 \
- --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \
- --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6
+starlette==1.4.1 \
+ --hash=sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19 \
+ --hash=sha256:b7332de6e9375593a29ba9eee1e6ecfeb3eb2043e2e19a13b4b71da73ff35540
# via
# apitally
# fastapi
@@ -2011,13 +2011,13 @@ starlette-admin==0.17.1 \
--hash=sha256:685615945d55de636879e3523ec70a6419176f7cd6f04a17470e35670f96b972 \
--hash=sha256:7bdeaf1c30fd9036ef3779fb0255002d3d18aaf6f7e674200e21c604ee563fc7
# via ocotilloapi
-tinydb==4.8.2 \
- --hash=sha256:f7dfc39b8d7fda7a1ca62a8dbb449ffd340a117c1206b68c50b1a481fb95181d \
- --hash=sha256:f97030ee5cbc91eeadd1d7af07ab0e48ceb04aa63d4a983adbaca4cba16e86c3
+tinydb==4.9.0 \
+ --hash=sha256:111f1f680978a1b7c534d698dd1d76739ff31c74c27fd26a111140b51d8b35d6 \
+ --hash=sha256:6928b1fa785186bda7952a0ba05aaeedc883ede565ca9c7d608de44e5e75de70
# via pygeoapi
-typer==0.27.0 \
- --hash=sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5 \
- --hash=sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1
+typer==0.27.1 \
+ --hash=sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56 \
+ --hash=sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df
# via ocotilloapi
types-pytz==2025.2.0.20250809 \
--hash=sha256:222e32e6a29bb28871f8834e8785e3801f2dc4441c715cd2082b271eecbe21e5 \
@@ -2064,13 +2064,13 @@ urllib3==2.7.0 \
# ocotilloapi
# requests
# sentry-sdk
-utm==0.8.1 \
- --hash=sha256:634d5b6221570ddc6a1e94afa5c51bae92bcead811ddc5c9bc0a20b847c2dafa \
- --hash=sha256:e3d5e224082af138e40851dcaad08d7f99da1cc4b5c413a7de34eabee35f434a
+utm==0.9.0 \
+ --hash=sha256:1c8ffa6032631379374ceef05e6fea0ad42e9f09be0c3f91f7cc1b23f27be8a7 \
+ --hash=sha256:767592281e457dfacd71323ac69ff38e2f290d74526af91ca0924637de0e1d53
# via ocotilloapi
-uvicorn==0.51.0 \
- --hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b \
- --hash=sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0
+uvicorn==0.52.1 \
+ --hash=sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd \
+ --hash=sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a
# via ocotilloapi
werkzeug==3.1.8 \
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
diff --git a/uv.lock b/uv.lock
index 6cecc7ccb..a9f1d2cb0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -137,16 +137,16 @@ wheels = [
[[package]]
name = "alembic"
-version = "1.18.5"
+version = "1.19.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fb/01/a48dab7827ac4421272399f7ed9a2ec17edd12c8bcde4417bd7b6821b71a/alembic-1.19.0.tar.gz", hash = "sha256:6487c612fc719dcfa22b17d2dd5b2b458929641e6aa2f0b65b135727f5e6d501", size = 2069906, upload-time = "2026-08-04T18:57:04.599Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" },
+ { url = "https://files.pythonhosted.org/packages/34/79/59ab6f1fe72eee229de91aa393225389a85df12a0fbbbfa264efcf6d7872/alembic-1.19.0-py3-none-any.whl", hash = "sha256:cf839d3849116aab3cc047e09c6968b9bd6b2fde61b6bb7c1e97352fe5503580", size = 265738, upload-time = "2026-08-04T18:57:06.219Z" },
]
[[package]]
@@ -395,11 +395,11 @@ wheels = [
[[package]]
name = "cachetools"
-version = "7.1.6"
+version = "7.1.7"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/55/af/861ebc2e318a5c3300e3eb63bc4d30f3d70a46d13b360093728ac0705eed/cachetools-7.1.6.tar.gz", hash = "sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1", size = 40572, upload-time = "2026-07-23T22:47:53.737Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/70/d2/47e8bc06fe2a06d3f5bdf20f1126ab66c4e99dc48d940e7ba873f7ac7131/cachetools-7.1.7.tar.gz", hash = "sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50", size = 40680, upload-time = "2026-08-01T21:20:40.434Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9f/f2/2086ba18a925a73586c4d4e61d25f4a6058e56fd00d77ce8f1d361ab4c9b/cachetools-7.1.6-py3-none-any.whl", hash = "sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096", size = 16954, upload-time = "2026-07-23T22:47:52.397Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d8/767faeda872075724b95dd675466a645f1b92aadcdcf2d1429dcfd76c176/cachetools-7.1.7-py3-none-any.whl", hash = "sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0", size = 16830, upload-time = "2026-08-01T21:20:38.977Z" },
]
[[package]]
@@ -413,75 +413,75 @@ wheels = [
[[package]]
name = "cffi"
-version = "2.1.0"
+version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
- { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
- { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
- { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
- { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
- { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
- { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
- { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
- { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
- { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
- { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
- { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
- { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
- { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
- { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
- { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
- { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
- { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
- { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
- { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
- { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
- { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
- { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
- { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
- { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
- { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
- { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
- { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
- { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
- { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
- { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
- { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
- { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
- { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
- { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
- { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
- { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
- { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
- { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
- { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
- { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
- { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
- { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
- { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
- { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
- { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
- { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
- { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
- { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
- { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
- { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
- { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
- { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
- { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
- { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
- { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
- { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
- { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
- { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
- { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
- { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
- { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
+ { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
+ { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
+ { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
+ { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
+ { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
+ { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
+ { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
+ { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
+ { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
+ { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
+ { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
+ { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
+ { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
+ { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
+ { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
+ { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
+ { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
+ { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
+ { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
+ { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
@@ -806,7 +806,7 @@ wheels = [
[[package]]
name = "fastapi"
-version = "0.140.2"
+version = "0.141.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -815,23 +815,23 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5b/fa/67d7232a733c5f5cea8859dcd6f6de78a688bb8886676808e3f5a8358008/fastapi-0.140.2.tar.gz", hash = "sha256:5f64faeb12339d783510db0498da660539195ac7b5bdf69c0fc558f4580a9724", size = 421280, upload-time = "2026-07-27T14:15:39.682Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/74/ed/15196be41f2bf84e358d899e62daf5666ba4f6dbab4cbad87d16cc16df6d/fastapi-0.140.2-py3-none-any.whl", hash = "sha256:944336ef298148dfd97478638567b223d929aba8717ad8be73ae962a62ecd61d", size = 130883, upload-time = "2026-07-27T14:15:38.099Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
]
[[package]]
name = "fastapi-pagination"
-version = "0.15.15"
+version = "0.15.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastapi" },
{ name = "pydantic" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f8/79/826b79dd718b0d9a91f21c3cfed365e5ecf44bc31419615c4cebeebb9ada/fastapi_pagination-0.15.15.tar.gz", hash = "sha256:d6e9e4bc4d6e20709dcabc11b16056cd5cd184c995ee214b0190f6b81426fa0c", size = 614873, upload-time = "2026-06-16T17:25:44.055Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/42/65/9c2f1c324fa7a83b328b024c45e70471007b165a1d56ab237fe0e574e36f/fastapi_pagination-0.15.16.tar.gz", hash = "sha256:739a4e904729dc01e03ce95e260ed9be050d48a3659be8463e5c4fcdd0cf25b0", size = 614997, upload-time = "2026-07-28T18:41:07.421Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c5/02/2e794ea498515afdadf2891f89aaed404de3ed619dd9cbf1ca637f17df19/fastapi_pagination-0.15.15-py3-none-any.whl", hash = "sha256:dc828d7cd15614c650c284bd2c3a98a8a2d9ce340508be3970dc8986908a02aa", size = 65864, upload-time = "2026-06-16T17:25:42.734Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/d1/9725ec62421dcfe430d874cba9f82c0288baee0c4b7dd8aa8dcca2b0394e/fastapi_pagination-0.15.16-py3-none-any.whl", hash = "sha256:86dc73620812d47c297a7b8baca4eba2bac5d3f1d73aa75dc6e3bb12c0b803f7", size = 65991, upload-time = "2026-07-28T18:41:06.076Z" },
]
[[package]]
@@ -962,7 +962,7 @@ wheels = [
[[package]]
name = "google-api-core"
-version = "2.33.0"
+version = "2.34.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-auth" },
@@ -971,9 +971,9 @@ dependencies = [
{ name = "protobuf" },
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/87/62/8fb1fb647d2788c950d69d6a769cd9d55c918ac1fc57be2f90b7e4029787/google_api_core-2.33.0.tar.gz", hash = "sha256:3a36bcc3e319783f4c97da41f6f45ea6ffcaa55848e341de16e09cb70243c2bb", size = 181607, upload-time = "2026-07-22T16:28:28.027Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/7c/9be3903e3d45415e8ca493c75f8990a0f6f579d168015d44c379350d0ab0/google_api_core-2.34.0.tar.gz", hash = "sha256:98a779fe72de956eb1c9c2f47ff4c4432a668ece1a002ec38bed07ec2698ae59", size = 187953, upload-time = "2026-08-06T06:23:58.128Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/89/31/5056a347bb934ea04583c8b27916ef1501729c72638629545bce26ff4223/google_api_core-2.33.0-py3-none-any.whl", hash = "sha256:a2e22a0c1d0f03eafff1858b38cf46f832d5902b0c052235bf0ab8402929fbdc", size = 176462, upload-time = "2026-07-22T16:28:22.447Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/c1/a8a92ae1bc4b1a8f804c776d7d3f0c771b78a62c3ad4df1be41b3fd8c767/google_api_core-2.34.0-py3-none-any.whl", hash = "sha256:cdf9c67e7ca2402d86ccbfde5f2503fc83e3cc3f58cc78456ae96cad24a6d2de", size = 180545, upload-time = "2026-08-06T06:22:47.502Z" },
]
[[package]]
@@ -994,15 +994,15 @@ wheels = [
[[package]]
name = "google-auth"
-version = "2.56.2"
+version = "2.56.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyasn1-modules" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c7/33/dbc946a407401b975f0719658f18e664ece2109f79ffd1ff3bf226c205f4/google_auth-2.56.2.tar.gz", hash = "sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051", size = 365820, upload-time = "2026-07-21T21:53:28.188Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/4c/fa42116a48bab3f7a143cf5042ecff7df9c8b73f8a376203cd534d1dc966/google_auth-2.56.3.tar.gz", hash = "sha256:40e229fc901f0a305b553050e5fce562d509bee0435be053abfa91582b51b90c", size = 367110, upload-time = "2026-08-06T06:24:01.36Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/88/63/50636aae68c9bf17c891c7eb18b49baa9bd6b31d2a97b8de4813a9fc8d1c/google_auth-2.56.2-py3-none-any.whl", hash = "sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6", size = 258588, upload-time = "2026-07-21T21:53:26.399Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/b3/6117b2f24065cd7e2c4f140e9a193e215f089ca8ba314cf91eb9d0b7fe0a/google_auth-2.56.3-py3-none-any.whl", hash = "sha256:8ec438808f813ad034535000261eed1067475d229d05bbf4216e78c3f2362e53", size = 259116, upload-time = "2026-08-06T06:22:51.788Z" },
]
[[package]]
@@ -1020,20 +1020,20 @@ wheels = [
[[package]]
name = "google-cloud-core"
-version = "2.6.0"
+version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
{ name = "google-auth" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4a/c6/9d7d9ed6703eb35306ca7bf381fb66ba8d978c61a1b550a2e1730a4c4ce8/google_cloud_core-2.6.1.tar.gz", hash = "sha256:1e044b131f2ae097b92312fa195164b0aeb6dc6a88e00231e1210516314c420c", size = 36017, upload-time = "2026-08-06T06:24:18.211Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/84/4a/98da8930ab109c73d9a5d13782a9ebb81ea8c111f6d534a567b71d23e52b/google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e", size = 29390, upload-time = "2026-05-07T08:02:34.672Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/4f/37960d5255988b218c40c9b5a2b731013684294a50735d1dd1ab4894e531/google_cloud_core-2.6.1-py3-none-any.whl", hash = "sha256:2682a8a4474a32f56292fb4bca7fa7e4fb0b4af958f6abfe4bca8d195747fd45", size = 29393, upload-time = "2026-08-06T06:23:11.405Z" },
]
[[package]]
name = "google-cloud-storage"
-version = "3.13.0"
+version = "3.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
@@ -1043,9 +1043,9 @@ dependencies = [
{ name = "google-resumable-media" },
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/e3/25/355ed97c1723c787dfaa888808d55db18371f82c38ff862357b1e902cd19/google_cloud_storage-3.13.0.tar.gz", hash = "sha256:d11d8706ea1520fba0f21043bcb7897caf7015d76ce1ad9a4f60237e4d7a9f6c", size = 17340960, upload-time = "2026-07-13T19:10:07.524Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/7e/73bb7512df1d1aad6ce3f9aed847cd40e0cd400ba4a85d86ab8eb412e9cc/google_cloud_storage-3.13.1.tar.gz", hash = "sha256:a80bf8cac2794808aa61c50c5f769ecbbe2d10331bacd0d69d30e59b14b346b2", size = 17341051, upload-time = "2026-08-06T06:24:42.229Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/81/e8/b3678a0931ee7d4b3fdaf0813e6206d66e0922b2c26d912f308728b5b95a/google_cloud_storage-3.13.0-py3-none-any.whl", hash = "sha256:648af3ef8a6acc674e1359d3c920c67eb89a7a5ab66b336bd3ac43fed6b5ab84", size = 341428, upload-time = "2026-07-13T19:09:52.39Z" },
+ { url = "https://files.pythonhosted.org/packages/06/6f/d69f0e185e08ddb58c323a0a935af2b492907b5de362bc08933b0a3b5644/google_cloud_storage-3.13.1-py3-none-any.whl", hash = "sha256:98208de6c21e85cecd3eb44551894efff33d98365500e178867d4305854a770a", size = 341486, upload-time = "2026-08-06T06:23:36.548Z" },
]
[[package]]
@@ -1068,26 +1068,26 @@ wheels = [
[[package]]
name = "google-resumable-media"
-version = "2.10.0"
+version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-crc32c" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/48/f8/1ca5781d6be9cb9f73f7d40f4958c4bd1226a60598e3e39e1d6aaf838c4b/google_resumable_media-2.10.0.tar.gz", hash = "sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee", size = 2164570, upload-time = "2026-06-03T16:14:26.103Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/f5/f35505e6091614e285056a495488cb0a9c1a9dcc88a4a3c91bbc5fd4835b/google_resumable_media-2.10.1.tar.gz", hash = "sha256:224975032ddb73f7ed9e2f0f4cc08ed1b06874c52d48cc8533e3eb72980b21a0", size = 2164548, upload-time = "2026-08-06T06:24:50.489Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b0/d8/00c6854ac1512bb9eaf13bd3f8f28222f7674947fc510a4ff7616f2efc80/google_resumable_media-2.10.0-py3-none-any.whl", hash = "sha256:88152884bee37b2bf36a0ab81ad8c7fd12212c9803dd981d77c1b35b02d34e7c", size = 81533, upload-time = "2026-06-03T16:13:12.51Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ba/77ef49baf338c03a11deadac984e3161b3f2b4fa4bb5aab160e7ca0fd522/google_resumable_media-2.10.1-py3-none-any.whl", hash = "sha256:4e2cbc704207ddc09f23b1f18e8ef4a4ccbfe0f1768b370e5c969704adbd0a1c", size = 81533, upload-time = "2026-08-06T06:23:45.464Z" },
]
[[package]]
name = "googleapis-common-protos"
-version = "1.75.0"
+version = "1.75.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/73/74bcab964c9a7a61f2bb71e8179b0f13e6fa98f7ce00fd168aab291e4a2e/googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071", size = 150967, upload-time = "2026-08-06T06:24:51.972Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/51/186c02b8549b69ccda44429cf6ff5081e4b61a602ddfe6a8020d1be31d1b/googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79", size = 300626, upload-time = "2026-08-06T06:23:46.696Z" },
]
[[package]]
@@ -1349,14 +1349,14 @@ wheels = [
[[package]]
name = "mako"
-version = "1.3.12"
+version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" },
]
[[package]]
@@ -1707,7 +1707,7 @@ requires-dist = [
{ name = "aiohttp", specifier = "==3.14.3" },
{ name = "aiosignal", specifier = "==1.4.0" },
{ name = "aiosqlite", specifier = "==0.22.1" },
- { name = "alembic", specifier = "==1.18.5" },
+ { name = "alembic", specifier = "==1.19.0" },
{ name = "annotated-types", specifier = "==0.8.0" },
{ name = "anyio", specifier = "==4.14.2" },
{ name = "apitally", extras = ["fastapi"], specifier = "==0.25.1" },
@@ -1717,9 +1717,9 @@ requires-dist = [
{ name = "attrs", specifier = "==26.1.0" },
{ name = "authlib", specifier = "==1.7.2" },
{ name = "bcrypt", specifier = "==4.3.0" },
- { name = "cachetools", specifier = "==7.1.6" },
+ { name = "cachetools", specifier = "==7.1.7" },
{ name = "certifi", specifier = "==2026.7.22" },
- { name = "cffi", specifier = "==2.1.0" },
+ { name = "cffi", specifier = "==2.1.1" },
{ name = "charset-normalizer", specifier = "==3.4.9" },
{ name = "click", specifier = "==8.4.2" },
{ name = "cloud-sql-python-connector", specifier = "==1.21.0" },
@@ -1727,17 +1727,17 @@ requires-dist = [
{ name = "dnspython", specifier = "==2.8.0" },
{ name = "dotenv", specifier = "==0.9.9" },
{ name = "email-validator", specifier = "==2.3.0" },
- { name = "fastapi", specifier = "==0.140.2" },
- { name = "fastapi-pagination", specifier = "==0.15.15" },
+ { name = "fastapi", specifier = "==0.141.1" },
+ { name = "fastapi-pagination", specifier = "==0.15.16" },
{ name = "frozenlist", specifier = "==1.8.0" },
{ name = "geoalchemy2", specifier = "==0.20.0" },
- { name = "google-api-core", specifier = "==2.33.0" },
- { name = "google-auth", specifier = "==2.56.2" },
- { name = "google-cloud-core", specifier = "==2.6.0" },
- { name = "google-cloud-storage", specifier = "==3.13.0" },
+ { name = "google-api-core", specifier = "==2.34.0" },
+ { name = "google-auth", specifier = "==2.56.3" },
+ { name = "google-cloud-core", specifier = "==2.6.1" },
+ { name = "google-cloud-storage", specifier = "==3.13.1" },
{ name = "google-crc32c", specifier = "==1.8.0" },
- { name = "google-resumable-media", specifier = "==2.10.0" },
- { name = "googleapis-common-protos", specifier = "==1.75.0" },
+ { name = "google-resumable-media", specifier = "==2.10.1" },
+ { name = "googleapis-common-protos", specifier = "==1.75.1" },
{ name = "greenlet", specifier = "==3.5.4" },
{ name = "gunicorn", specifier = "==23.0.0" },
{ name = "h11", specifier = "==0.16.0" },
@@ -1746,19 +1746,19 @@ requires-dist = [
{ name = "idna", specifier = "==3.18" },
{ name = "iniconfig", specifier = "==2.3.0" },
{ name = "jinja2", specifier = "==3.1.6" },
- { name = "mako", specifier = "==1.3.12" },
+ { name = "mako", specifier = "==1.4.1" },
{ name = "markupsafe", specifier = "==3.0.3" },
{ name = "multidict", specifier = "==6.7.1" },
{ name = "numpy", specifier = "==2.5.1" },
- { name = "packaging", specifier = "==26.2" },
+ { name = "packaging", specifier = "==26.3" },
{ name = "pandas", specifier = "==2.3.2" },
{ name = "pandas-stubs", specifier = "~=2.3.2" },
{ name = "pg8000", specifier = "==1.31.5" },
- { name = "phonenumbers", specifier = "==9.0.35" },
+ { name = "phonenumbers", specifier = "==9.0.36" },
{ name = "pillow", specifier = "==12.3.0" },
{ name = "pluggy", specifier = "==1.6.0" },
{ name = "propcache", specifier = "==0.5.2" },
- { name = "proto-plus", specifier = "==1.28.2" },
+ { name = "proto-plus", specifier = "==1.28.3" },
{ name = "protobuf", specifier = "==6.33.5" },
{ name = "psycopg2-binary", specifier = ">=2.9.12" },
{ name = "pyasn1", specifier = "==0.6.4" },
@@ -1766,7 +1766,7 @@ requires-dist = [
{ name = "pycparser", specifier = "==3.0" },
{ name = "pydantic", specifier = "==2.12.5" },
{ name = "pydantic-core", specifier = "==2.41.5" },
- { name = "pygeoapi", specifier = "==0.23.5" },
+ { name = "pygeoapi", specifier = "==0.24.0" },
{ name = "pygments", specifier = "==2.20.0" },
{ name = "pyjwt", specifier = "==2.13.0" },
{ name = "pymssql", specifier = ">=2.3.13" },
@@ -1778,7 +1778,7 @@ requires-dist = [
{ name = "pytz", specifier = "==2026.3.post1" },
{ name = "requests", specifier = "==2.34.2" },
{ name = "rsa", specifier = "==4.9.1" },
- { name = "scramp", specifier = "==1.4.15" },
+ { name = "scramp", specifier = "==1.4.16" },
{ name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.66.1" },
{ name = "shapely", specifier = "==2.1.2" },
{ name = "six", specifier = "==1.17.0" },
@@ -1788,14 +1788,14 @@ requires-dist = [
{ name = "sqlalchemy-searchable", specifier = "==2.1.0" },
{ name = "sqlalchemy-utils", specifier = "==0.42.1" },
{ name = "sqlparse", specifier = ">=0.5.5" },
- { name = "starlette", specifier = "==1.3.1" },
- { name = "typer", specifier = "==0.27.0" },
+ { name = "starlette", specifier = "==1.4.1" },
+ { name = "typer", specifier = "==0.27.1" },
{ name = "typing-extensions", specifier = "==4.16.0" },
{ name = "typing-inspection", specifier = "==0.4.2" },
{ name = "tzdata", specifier = "==2026.3" },
{ name = "urllib3", specifier = "==2.7.0" },
- { name = "utm", specifier = "==0.8.1" },
- { name = "uvicorn", specifier = "==0.51.0" },
+ { name = "utm", specifier = "==0.9.0" },
+ { name = "uvicorn", specifier = "==0.52.1" },
{ name = "yarl", specifier = "==1.24.5" },
]
@@ -1871,11 +1871,11 @@ wheels = [
[[package]]
name = "packaging"
-version = "26.2"
+version = "26.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
@@ -1964,11 +1964,11 @@ wheels = [
[[package]]
name = "phonenumbers"
-version = "9.0.35"
+version = "9.0.36"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/47/9c/af506fdd7220cdf6fcea27a3b8d3dc5feb239f5072137c7900155c0c6cf9/phonenumbers-9.0.35.tar.gz", hash = "sha256:b19d97e8c448ccfd8d646888d2a65635372f75b9916006cc0a2f809b531baaea", size = 2306830, upload-time = "2026-07-26T08:28:17.303Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/21/3f1561d3527b27121d6872aeecb6df2a8de8e11af9c36f5a0bd3483d2655/phonenumbers-9.0.36.tar.gz", hash = "sha256:60ca2a6870d2532d6e960105790e85e9d69270ede81746d10cf57f5e437b93ec", size = 2307271, upload-time = "2026-08-01T06:13:44.954Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/62/35/80f35904033e5584966c255a0140f48934d18c4711c04d4bcbdf5b330bfb/phonenumbers-9.0.35-py2.py3-none-any.whl", hash = "sha256:57ef9787ddf2bc8cc0906d5c876d43fcd65fa25e7330d00b9f2ba8528870b72a", size = 2595440, upload-time = "2026-07-26T08:28:14.033Z" },
+ { url = "https://files.pythonhosted.org/packages/39/5d/74fbcb16b29927bf378f4ec4d47c7b48488b93e1f1af64b26544daf1d55f/phonenumbers-9.0.36-py2.py3-none-any.whl", hash = "sha256:c16a5b95178345ec2df1954578a4ac09e937443b2ee992dd7b15a8e8c81f4acf", size = 2595528, upload-time = "2026-08-01T06:13:41.591Z" },
]
[[package]]
@@ -2146,14 +2146,14 @@ wheels = [
[[package]]
name = "proto-plus"
-version = "1.28.2"
+version = "1.28.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/73/3e/29e0d6a2c5adde6ab5772253fd16ab346324026b89a66e354689c86d0584/proto_plus-1.28.2.tar.gz", hash = "sha256:26d843eb99c1e32fdf1d20ff0faae56607f7748fe774acf9ecd5cfe6c6472501", size = 58063, upload-time = "2026-07-22T16:28:29.119Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/6a/056256feb4bd000869aba5c16cf2aa911572ca2a2feb185f86e457b5171e/proto_plus-1.28.3.tar.gz", hash = "sha256:5f91b30dafa6bb38d432c5557a6ee1d35ffd40b4b1e0e3ca27260448560b91d9", size = 58051, upload-time = "2026-08-06T06:24:55.581Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9d/84/4e9a53a062d4073c74897a6bd20fff74d55307341b3e85c081002462b3ef/proto_plus-1.28.2-py3-none-any.whl", hash = "sha256:b874236fcac2358f601e4330bcb76cb8b89c851303ccf4078408b3d4774d1c52", size = 50693, upload-time = "2026-07-22T16:28:24.059Z" },
+ { url = "https://files.pythonhosted.org/packages/61/3a/cfee3c50294f55a2f0f9575052dec2c2a48891ad4b1c2a133b05a87026cd/proto_plus-1.28.3-py3-none-any.whl", hash = "sha256:dc76880b8ee951cca002098574376cf71e055f9f16d9ba6570fb8a06f726d281", size = 50795, upload-time = "2026-08-06T06:23:50.653Z" },
]
[[package]]
@@ -2347,7 +2347,7 @@ wheels = [
[[package]]
name = "pygeoapi"
-version = "0.23.5"
+version = "0.24.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "babel" },
@@ -2369,9 +2369,9 @@ dependencies = [
{ name = "sqlalchemy" },
{ name = "tinydb" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b0/bd/627893d1170ec92230cbcbdc2bb76732e9977d2addf3564f657676802a45/pygeoapi-0.23.5.tar.gz", hash = "sha256:9f1456738a8851c582f7336159dfaaa96ca9704b9c554c0cc59d699042314359", size = 372861, upload-time = "2026-07-14T13:54:56.417Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/be/6c658b14b0b73a301ae7c71298a0046cbea7ba08f029a22fa0a11394b9b6/pygeoapi-0.24.0.tar.gz", hash = "sha256:00cf5c88776284780bb8bcc82f4f48c52d25ddfefc9ba1a65a8f3f811670a0a3", size = 386049, upload-time = "2026-07-28T12:12:17.028Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2d/68/c5d54267698706a90bed15230f4d948c99d838dd4fbdabf9586797e2415c/pygeoapi-0.23.5-py2.py3-none-any.whl", hash = "sha256:66ec6c466f00a2ec4af77b886bc4b046d70a2fde4cdf21021e551231db955e19", size = 578221, upload-time = "2026-07-14T13:54:55.025Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/f9/b5c7eb51bb73c102e331b7afdcb7413cb3e66418af4ad8910a19acf48caf/pygeoapi-0.24.0-py2.py3-none-any.whl", hash = "sha256:0ca8063121c19b86c90e896ed72758ce942be6c4a232062cc919ff7a8af36435", size = 599073, upload-time = "2026-07-28T12:12:15.854Z" },
]
[[package]]
@@ -2873,14 +2873,14 @@ wheels = [
[[package]]
name = "scramp"
-version = "1.4.15"
+version = "1.4.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asn1crypto" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/6dd9b1e7ff7973eeb6550d0d609c3bdca8f27de2719a837628c9fd44c087/scramp-1.4.15.tar.gz", hash = "sha256:d25cdd3dbc493773647bccb93e4e85bbff0c091141ec8ff8d61bad1e3638082d", size = 20958, upload-time = "2026-07-26T17:49:06.655Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/3f/dc633be307f4f9f5e41f9374da6bb2b4f0332fd388b1d92306909a308bcf/scramp-1.4.16.tar.gz", hash = "sha256:836bcecdc8843af76b35e11adbe818e3d7934d0d25aa48fd2e8cde362a2c6a9b", size = 21210, upload-time = "2026-08-06T16:48:56.725Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/a4/6a6e67a8bdbc17b89537de2886cef62536d47c25b1f1d85ecfa81a4915f1/scramp-1.4.15-py3-none-any.whl", hash = "sha256:9d6102948d9005e3802384a328429dfd67d691a65791007c354ff89895857396", size = 15891, upload-time = "2026-07-26T17:49:04.913Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/f0/5441d2bac230fa9a197c081672b57bdf2641e3988dddd39c687a3ad5267b/scramp-1.4.16-py3-none-any.whl", hash = "sha256:9e6148fca008ab6c6ce84658aeefd03d5d7f269d32fe08d07e134dae089e937a", size = 16083, upload-time = "2026-08-06T16:48:55.088Z" },
]
[[package]]
@@ -3053,14 +3053,14 @@ wheels = [
[[package]]
name = "starlette"
-version = "1.3.1"
+version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/3c/76d2fd1f1357ed0f0108d8a5aa233dcf16e2946a8559c84912fe08e01ac7/starlette-1.4.1.tar.gz", hash = "sha256:b7332de6e9375593a29ba9eee1e6ecfeb3eb2043e2e19a13b4b71da73ff35540", size = 2709041, upload-time = "2026-08-05T15:17:26.23Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
+ { url = "https://files.pythonhosted.org/packages/75/0a/67e95f21498de41433babf7b1db0eeab449eb58872dfb831b27747a70fd0/starlette-1.4.1-py3-none-any.whl", hash = "sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19", size = 74019, upload-time = "2026-08-05T15:17:24.357Z" },
]
[[package]]
@@ -3074,7 +3074,7 @@ wheels = [
[[package]]
name = "typer"
-version = "0.27.0"
+version = "0.27.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -3082,9 +3082,9 @@ dependencies = [
{ name = "rich" },
{ name = "shellingham" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" },
+ { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" },
]
[[package]]
@@ -3158,24 +3158,24 @@ wheels = [
[[package]]
name = "utm"
-version = "0.8.1"
+version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/76/c4/f7662574e0d8c883cea257a59efdc2dbb21f19f4a78e7c54be570d740f24/utm-0.8.1.tar.gz", hash = "sha256:634d5b6221570ddc6a1e94afa5c51bae92bcead811ddc5c9bc0a20b847c2dafa", size = 13128, upload-time = "2025-03-06T11:40:56.022Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/9a/d2cd5fec4f65f963fdd4f7a8a7f8992edeae7830751a1d8bd7261703f3fb/utm-0.9.0.tar.gz", hash = "sha256:1c8ffa6032631379374ceef05e6fea0ad42e9f09be0c3f91f7cc1b23f27be8a7", size = 13381, upload-time = "2026-07-27T15:34:48.603Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/a4/0698f3e5c397442ec9323a537e48cc63b846288b6878d38efd04e91005e3/utm-0.8.1-py3-none-any.whl", hash = "sha256:e3d5e224082af138e40851dcaad08d7f99da1cc4b5c413a7de34eabee35f434a", size = 8613, upload-time = "2025-03-06T11:40:54.273Z" },
+ { url = "https://files.pythonhosted.org/packages/45/d2/ccf2f47bfeb607db0c09865dea382f6ec5e6c9b93416f98452633f3c1c8b/utm-0.9.0-py3-none-any.whl", hash = "sha256:767592281e457dfacd71323ac69ff38e2f290d74526af91ca0924637de0e1d53", size = 8608, upload-time = "2026-07-27T15:34:47.651Z" },
]
[[package]]
name = "uvicorn"
-version = "0.51.0"
+version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" },
]
[[package]]
From e5748a540aec737136ac13795c2f9f917f198c37 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Mon, 10 Aug 2026 13:40:02 -0700
Subject: [PATCH 044/151] feat(geothermal): free-text search on the well list
endpoint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds `q` to GET /thing/geothermal-well, matched case-insensitively as a
substring across well name, API, well number, operator and county.
The UI well picker cannot offer this catalogue as a scrollable list — it runs
to thousands of wells, and the picker previously loaded the first 500 into a
dropdown and silently dropped the rest. Narrowing has to happen server-side so
the pagination envelope's `total` is the size of the match set, which is what
the picker reports to the user.
Whitespace-separated words are ANDed, so each word added narrows the result:
"jemez 1" returns a subset of "jemez" rather than a union. ILIKE wildcards in
the term are escaped, so a stray % matches a literal percent instead of
silently widening the search to everything.
`county` and `name_contains` are unchanged and compose with `q`.
Tests compile the query and assert its shape, so they need no database: every
column is searched, words AND rather than OR, wildcards are escaped, the
geothermal base predicate survives, and ordering is preserved.
Co-Authored-By: Claude Opus 5
---
api/geothermal.py | 16 ++++-
services/geothermal_helper.py | 36 +++++++++-
tests/test_geothermal_well_search.py | 99 ++++++++++++++++++++++++++++
3 files changed, 148 insertions(+), 3 deletions(-)
create mode 100644 tests/test_geothermal_well_search.py
diff --git a/api/geothermal.py b/api/geothermal.py
index 0e79368d9..8efd63094 100644
--- a/api/geothermal.py
+++ b/api/geothermal.py
@@ -25,7 +25,7 @@
from typing import Optional
from uuid import UUID
-from fastapi import APIRouter
+from fastapi import APIRouter, Query
from fastapi_pagination.ext.sqlalchemy import paginate
from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND
@@ -52,13 +52,25 @@ def get_geothermal_wells(
session: session_dependency,
county: Optional[str] = None,
name_contains: Optional[str] = None,
+ q: Optional[str] = Query(
+ None,
+ description=(
+ "Free-text search across well name, API, well number, operator and "
+ "county. Whitespace-separated words are ANDed, so each word added "
+ "narrows the result. Case-insensitive substring match."
+ ),
+ ),
) -> CustomPage[GeothermalWellResponse]:
"""List geothermal wells.
NOTE: sourced from the legacy NM_Wells mirror (NMW_WellHeaders where
GthrmExist is set). Will be re-pointed at the thing table post-transform.
+
+ ``q`` is what the UI well picker uses: the catalogue is far too large to
+ choose from by scrolling, so the term is matched server-side and the total
+ reported by the pagination envelope is the size of the match set.
"""
- sql = get_geothermal_wells_query(county=county, name_contains=name_contains)
+ sql = get_geothermal_wells_query(county=county, name_contains=name_contains, q=q)
return paginate(query=sql, conn=session, transformer=geothermal_wells_transformer)
diff --git a/services/geothermal_helper.py b/services/geothermal_helper.py
index 5fc10f9fd..56b78931d 100644
--- a/services/geothermal_helper.py
+++ b/services/geothermal_helper.py
@@ -27,7 +27,7 @@
from uuid import UUID
-from sqlalchemy import select
+from sqlalchemy import or_, select
from db.nmw_legacy import NMW_WellHeaders, NMW_WellLocations
from schemas.geothermal import GeothermalWellResponse
@@ -67,9 +67,40 @@ def _to_response(header: NMW_WellHeaders, location: NMW_WellLocations | None):
)
+# Columns a free-text term is matched against. Everything a person might use
+# to refer to a well: what it is called, how it is identified, and who ran it.
+_SEARCH_COLUMNS = (
+ NMW_WellHeaders.cur_well_nam,
+ NMW_WellHeaders.api,
+ NMW_WellHeaders.cur_well_num,
+ NMW_WellHeaders.cur_operatr,
+ NMW_WellLocations.county,
+)
+
+
+def _search_clauses(q: str):
+ """One clause per whitespace-separated word, each matching any column.
+
+ Words are ANDed so that adding a word narrows the result — "jemez 1"
+ returns a subset of "jemez" rather than everything matching either. ILIKE
+ wildcards in the term are escaped so a stray % does not silently widen the
+ search to everything.
+ """
+ clauses = []
+ for word in q.split():
+ pattern = "%{}%".format(
+ word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+ )
+ clauses.append(
+ or_(*[column.ilike(pattern, escape="\\") for column in _SEARCH_COLUMNS])
+ )
+ return clauses
+
+
def get_geothermal_wells_query(
county: str | None = None,
name_contains: str | None = None,
+ q: str | None = None,
):
"""Build the list query; returned as a SQLAlchemy select for pagination."""
sql = _base_query()
@@ -77,6 +108,9 @@ def get_geothermal_wells_query(
sql = sql.where(NMW_WellLocations.county == county)
if name_contains:
sql = sql.where(NMW_WellHeaders.cur_well_nam.ilike(f"%{name_contains}%"))
+ if q and q.strip():
+ for clause in _search_clauses(q.strip()):
+ sql = sql.where(clause)
return sql.order_by(NMW_WellHeaders.cur_well_nam)
diff --git a/tests/test_geothermal_well_search.py b/tests/test_geothermal_well_search.py
new file mode 100644
index 000000000..7c39c7940
--- /dev/null
+++ b/tests/test_geothermal_well_search.py
@@ -0,0 +1,99 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Free-text search on the geothermal well list query.
+
+The UI well picker cannot offer the catalogue as a scrollable list, so it sends
+a term and relies on the server to narrow it. These tests compile the query and
+assert its shape, which needs no database.
+"""
+
+from services.geothermal_helper import get_geothermal_wells_query
+
+# Columns `q` is matched against, by their legacy NM_Wells names.
+SEARCH_COLUMNS = ("CurWellNam", "API", "CurWellNum", "CurOperatr", "County")
+
+
+def compiled(**kwargs) -> str:
+ sql = get_geothermal_wells_query(**kwargs)
+ return str(sql.compile(compile_kwargs={"literal_binds": True}))
+
+
+def test_no_search_term_adds_no_predicate():
+ """An empty picker lists wells; it must not search for nothing."""
+ baseline = compiled()
+
+ assert compiled(q=None) == baseline
+ assert compiled(q="") == baseline
+ assert compiled(q=" ") == baseline
+ assert "lower" not in baseline.lower() or "ilike" not in baseline.lower()
+
+
+def test_term_matches_every_search_column():
+ sql = compiled(q="jemez")
+
+ for column in SEARCH_COLUMNS:
+ assert column in sql, f"{column} is not searched"
+ assert sql.lower().count("%jemez%") == len(SEARCH_COLUMNS)
+
+
+def test_term_is_case_insensitive_substring():
+ sql = compiled(q="jemez").lower()
+
+ # ILIKE compiles to lower(...) LIKE lower(...) on some dialects; either way
+ # the match is a substring wrapped in wildcards, not an equality.
+ assert "%jemez%" in sql
+ assert "like" in sql
+
+
+def test_words_are_anded_so_each_one_narrows():
+ """ "jemez 1" must be a subset of "jemez", not a union."""
+ one_word = compiled(q="jemez")
+ two_words = compiled(q="jemez 1")
+
+ assert "%jemez%" in two_words
+ assert "%1%" in two_words
+ # A second word adds a second bracketed OR group rather than extending the
+ # first, which is what makes the predicates AND together.
+ assert two_words.count("OR") > one_word.count("OR")
+
+
+def test_wildcards_in_the_term_are_escaped():
+ """A stray % must not silently widen the search to the whole catalogue."""
+ sql = compiled(q="50%")
+
+ assert r"%50\%%" in sql
+ assert "ESCAPE" in sql.upper()
+
+ underscore = compiled(q="a_b")
+ assert r"%a\_b%" in underscore
+
+
+def test_search_composes_with_the_existing_filters():
+ sql = compiled(q="jemez", county="Sandoval")
+
+ assert "%jemez%" in sql
+ assert "Sandoval" in sql
+ # The geothermal flag is the base predicate and must survive.
+ assert "GthrmExist" in sql
+
+
+def test_results_stay_ordered_by_name():
+ assert (
+ compiled(q="jemez").rstrip().endswith('ORDER BY "NMW_WellHeaders"."CurWellNam"')
+ )
+
+
+# ============= EOF =============================================
From e448741fe6a733a72f2e994b239c915dc4318ca0 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Thu, 13 Aug 2026 13:11:43 -0700
Subject: [PATCH 045/151] fix(db): repair EDR water views skipped by a stamped
revision
The staging database is stamped past z9a0b1c2d3e4 without that revision's
DDL ever having run, so every OGC API - EDR query 500s with:
psycopg2.errors.UndefinedTable: relation "ogc_waterlevels" does not exist
The CD run carrying z9a0b1c2d3e4 to staging failed in the Alembic step with
"Multiple head revisions are present for given argument 'head'". The graph was
repaired afterwards (eb89d046, 8ae9fe18), but the database came out the other
side with the revision recorded and its views absent. Downstream revisions
applied normally, so nothing surfaced until an EDR request hit the missing
relation. Re-running the revision is not possible (alembic_version already
lists it) and downgrading to it would tear out every revision since, so this
closes the hole from the front of the chain instead.
The view SQL is imported from z9a0b1c2d3e4 rather than copied, so the repaired
definition cannot drift from the definition of record. A view that is already
present as a plain view is left untouched, making this a no-op on healthy
databases; a name occupied by some other relation kind fails loudly rather than
being replaced. downgrade() is deliberately a no-op, since the views belong to
z9a0b1c2d3e4.
Verified against ocotilloapi_test: no-op on a healthy database (viewdefs
byte-identical), both views restored byte-identical after simulating the
staging state, and a materialized view squatting the name raises.
Co-Authored-By: Claude Opus 5
---
...d9e0f1a2_repair_missing_edr_water_views.py | 126 ++++++++++++++++++
1 file changed, 126 insertions(+)
create mode 100644 alembic/versions/b7c8d9e0f1a2_repair_missing_edr_water_views.py
diff --git a/alembic/versions/b7c8d9e0f1a2_repair_missing_edr_water_views.py b/alembic/versions/b7c8d9e0f1a2_repair_missing_edr_water_views.py
new file mode 100644
index 000000000..572c14b89
--- /dev/null
+++ b/alembic/versions/b7c8d9e0f1a2_repair_missing_edr_water_views.py
@@ -0,0 +1,126 @@
+"""repair missing EDR water views
+
+Recreates ogc_waterlevels / ogc_water_chemistry on any database whose
+alembic_version claims z9a0b1c2d3e4 was applied while the views are in fact
+absent.
+
+Why this is needed: the CD run that first carried z9a0b1c2d3e4 to staging
+failed in the Alembic step with "Multiple head revisions are present for given
+argument 'head'". The revision graph was then repaired in-tree (eb89d046,
+8ae9fe18), but the staging database came out the other side stamped past
+z9a0b1c2d3e4 without its DDL ever having executed. Downstream revisions applied
+normally, so nothing surfaced until an EDR query hit the missing relation:
+
+ psycopg2.errors.UndefinedTable: relation "ogc_waterlevels" does not exist
+
+Re-running z9a0b1c2d3e4 is not an option -- alembic_version already lists it,
+and downgrading to it would tear out every revision since. This revision closes
+the hole from the front of the chain instead.
+
+The view SQL is imported from z9a0b1c2d3e4 rather than copied so the repaired
+definition cannot drift from the definition of record.
+
+Idempotent and safe on healthy databases: a view that is already present is
+left untouched, so this is a no-op everywhere except the environments that
+actually skipped the original revision.
+
+Revision ID: b7c8d9e0f1a2
+Revises: f3a1c2b4d5e6
+Create Date: 2026-08-13 13:20:00.000000
+"""
+
+import importlib.util
+from pathlib import Path
+from typing import Sequence, Union
+
+from alembic import op
+from sqlalchemy import inspect, text
+
+# revision identifiers, used by Alembic.
+revision: str = "b7c8d9e0f1a2"
+down_revision: Union[str, Sequence[str], None] = "f3a1c2b4d5e6"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+_SOURCE_REVISION = "z9a0b1c2d3e4_add_edr_water_views.py"
+
+
+def _load_source_revision():
+ # The view definitions live in z9a0b1c2d3e4. Importing them keeps this
+ # repair honest: whatever that revision creates is exactly what a database
+ # that skipped it gets back.
+ path = Path(__file__).with_name(_SOURCE_REVISION)
+ if not path.exists():
+ raise RuntimeError(
+ f"Cannot repair the EDR water views: {_SOURCE_REVISION} is missing "
+ "from alembic/versions, so the view definitions of record are "
+ "unavailable."
+ )
+ spec = importlib.util.spec_from_file_location("_edr_water_views", path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+VIEW_COMMENTS = {
+ "ogc_waterlevels": (
+ "Public depth-to-water readings (manual + transducer) for EDR."
+ ),
+ "ogc_water_chemistry": "Public water-chemistry analyses (by analyte) for EDR.",
+}
+
+
+def _relkind(view_name: str) -> str | None:
+ bind = op.get_bind()
+ return bind.execute(
+ text("SELECT relkind FROM pg_class WHERE oid = to_regclass(:name)"),
+ {"name": view_name},
+ ).scalar()
+
+
+def _check_required_tables(required_tables: set[str]) -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ existing = set(inspector.get_table_names(schema="public"))
+ missing = required_tables - existing
+ if missing:
+ raise RuntimeError(
+ "Cannot repair the EDR water views. Missing required tables: "
+ f"{sorted(missing)}"
+ )
+
+
+def _repair_view(view_name: str, create_sql: str) -> None:
+ relkind = _relkind(view_name)
+ if relkind == "v":
+ # Already present and the right kind -- the database applied
+ # z9a0b1c2d3e4 for real. Leave it alone rather than churning DDL that
+ # other objects may depend on.
+ return
+ if relkind is not None:
+ # Present as something other than a plain view (materialized view,
+ # table). That is not a state z9a0b1c2d3e4 or its downstream revisions
+ # produce, so fail loudly instead of silently replacing it.
+ raise RuntimeError(
+ f"Cannot repair {view_name}: it already exists with relkind "
+ f"{relkind!r}, not a plain view. Inspect it by hand before "
+ "re-running this migration."
+ )
+
+ op.execute(text(create_sql))
+ op.execute(text(f"COMMENT ON VIEW {view_name} IS '{VIEW_COMMENTS[view_name]}'"))
+
+
+def upgrade() -> None:
+ source = _load_source_revision()
+ _check_required_tables(set(source.REQUIRED_TABLES))
+
+ _repair_view("ogc_waterlevels", source._create_waterlevels_view())
+ _repair_view("ogc_water_chemistry", source._create_water_chemistry_view())
+
+
+def downgrade() -> None:
+ # Deliberately a no-op. These views belong to z9a0b1c2d3e4; dropping them
+ # here would break EDR on every database that applied that revision
+ # correctly. Downgrading past z9a0b1c2d3e4 removes them.
+ pass
From bb447f67e426217001cae620b2462ed81ad5c664 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Thu, 13 Aug 2026 14:23:55 -0700
Subject: [PATCH 046/151] ci: add a workflow_dispatch job for data migrations
Data migrations in data_migrations/migrations/ have no CI path. The deploy
workflows run `alembic upgrade head` and stop there, so a registered data
migration stays unapplied until someone runs `oco data-migrations run` by hand
against a live database -- which needs Cloud SQL credentials most of the team
does not have locally. 20260714_0001_publish_project_areas has been sitting
unapplied on staging since July 14 for exactly this reason, leaving the
project_areas OGC collection advertised and empty.
This workflow runs them with the same environment secrets the deploys already
use, so no local credentials are involved.
Inputs: environment (staging/production, selecting the GitHub Environment and
therefore its secrets), action (status / run-all / run), migration_id,
include_repeatable, and force. The default action is status, which applies
nothing.
The action is bracketed by a status report before and after, so one run's log
shows what changed. A `run` without a migration_id fails in a validation step
before any credential is touched, rather than after connecting. Concurrency is
keyed on the environment: these write to a live database, and two concurrent
runs could both pass the "already applied" check.
Co-Authored-By: Claude Opus 5
---
.github/workflows/data_migrations.yml | 139 ++++++++++++++++++++++++++
1 file changed, 139 insertions(+)
create mode 100644 .github/workflows/data_migrations.yml
diff --git a/.github/workflows/data_migrations.yml b/.github/workflows/data_migrations.yml
new file mode 100644
index 000000000..b8af98622
--- /dev/null
+++ b/.github/workflows/data_migrations.yml
@@ -0,0 +1,139 @@
+name: Data Migrations
+
+# Data migrations (data_migrations/migrations/) are not part of CD. The deploy
+# workflows only run `alembic upgrade head`, so a registered data migration sits
+# unapplied until someone runs it by hand against a live database -- which needs
+# Cloud SQL credentials most people do not have locally. This workflow runs them
+# with the same environment secrets the deploys already use.
+#
+# Start with action = status. It prints what is registered, what has been
+# applied, and when. It applies no migration, though it is not strictly
+# read-only: get_status() calls ensure_history_table(), so a database that has
+# never run one gets an empty data_migration_history table created.
+
+on:
+ workflow_dispatch:
+ inputs:
+ environment:
+ description: "Target environment"
+ type: choice
+ options:
+ - staging
+ - production
+ default: staging
+ action:
+ description: "status (applies nothing) | run-all | run (single migration)"
+ type: choice
+ options:
+ - status
+ - run-all
+ - run
+ default: status
+ migration_id:
+ description: "Migration id -- required when action = run"
+ type: string
+ default: ""
+ include_repeatable:
+ description: "Include repeatable migrations (action = run-all)"
+ type: boolean
+ default: false
+ force:
+ description: "Re-run migrations already recorded as applied"
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+
+# One run at a time per environment: these write to a live database, and two
+# concurrent runs could both pass the "already applied" check.
+concurrency:
+ group: data-migrations-${{ inputs.environment }}
+ cancel-in-progress: false
+
+jobs:
+ data-migrations:
+ runs-on: ubuntu-latest
+ environment: ${{ inputs.environment }}
+
+ steps:
+ - name: Validate inputs
+ run: |
+ if [ "${{ inputs.action }}" = "run" ] && [ -z "${{ inputs.migration_id }}" ]; then
+ echo "::error::action = run requires migration_id."
+ echo "::error::Run this workflow with action = status to list the registered ids."
+ exit 1
+ fi
+ if [ "${{ inputs.action }}" = "status" ] && [ "${{ inputs.force }}" = "true" ]; then
+ echo "::warning::force has no effect on a status check."
+ fi
+
+ - name: Check out source repository
+ uses: actions/checkout@v7.0.1
+
+ - name: Install uv in container
+ uses: astral-sh/setup-uv@v9.0.0
+ with:
+ version: "latest"
+
+ - name: Authenticate to Google Cloud
+ uses: "google-github-actions/auth@v3"
+ with:
+ credentials_json: ${{ secrets.CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY }}
+
+ - name: Report target
+ run: |
+ echo "Environment: ${{ inputs.environment }}"
+ echo "Database: ${{ vars.CLOUD_SQL_DATABASE }}"
+ echo "Action: ${{ inputs.action }}"
+
+ # Runs before the action so the log shows the before/after pair on a
+ # single run, and so a status-only run needs no second step.
+ - name: Status before
+ env:
+ DB_DRIVER: "cloudsql"
+ CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}"
+ CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}"
+ CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}"
+ CLOUD_SQL_IAM_AUTH: true
+ run: uv run --no-dev oco data-migrations status
+
+ - name: Apply migrations
+ if: inputs.action != 'status'
+ env:
+ DB_DRIVER: "cloudsql"
+ CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}"
+ CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}"
+ CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}"
+ CLOUD_SQL_IAM_AUTH: true
+ run: |
+ set -euo pipefail
+
+ args=()
+ if [ "${{ inputs.action }}" = "run" ]; then
+ args=(run "${{ inputs.migration_id }}")
+ if [ "${{ inputs.force }}" = "true" ]; then
+ args+=(--force)
+ fi
+ else
+ args=(run-all)
+ if [ "${{ inputs.include_repeatable }}" = "true" ]; then
+ args+=(--include-repeatable)
+ fi
+ if [ "${{ inputs.force }}" = "true" ]; then
+ args+=(--force)
+ fi
+ fi
+
+ echo "oco data-migrations ${args[*]}"
+ uv run --no-dev oco data-migrations "${args[@]}"
+
+ - name: Status after
+ if: inputs.action != 'status'
+ env:
+ DB_DRIVER: "cloudsql"
+ CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}"
+ CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}"
+ CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}"
+ CLOUD_SQL_IAM_AUTH: true
+ run: uv run --no-dev oco data-migrations status
From 395a63c95712d241d93960442ff9c94981503a90 Mon Sep 17 00:00:00 2001
From: Likitha Bommasani
Date: Thu, 13 Aug 2026 14:58:11 -0700
Subject: [PATCH 047/151] fix: correct water_wells collection name in README
examples and Added time_field(BDMS-973) (#823)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
### Why
- README examples referenced a wells collection that doesn't exist —
both example URLs 404'd (Section 4.6, R7), the first thing a new
developer hits when following the docs.
- Correct collection name is water_wells.
### How
- Updated README.md lines 50 & 59: collections/wells/ →
collections/water_wells/.
- Discovered the datetime-filtered example still failed (500) after the
rename, because no Thing-based collection (water_wells, springs,
diversions_surface_water, ephemeral_streams, lakes_ponds_reservoirs) had
a time_field configured, so pygeoapi rejected any ?datetime= query.
- Added "time_field": "first_visit_date" in _thing_collections_block
(core/pygeoapi.py) — the one date column populated across all five
collections — fixing datetime filtering for all of them, not just
water_wells.
### Notes
- Verified both README example URLs now return 200 with valid GeoJSON
against a local stack.
- well_completion_date was considered as the time_field but is there are
many NULL values , so it wasn't usable - Can be discussed.
- ogc_locations collection is untouched — it's set up separately and
wasn't part of this bug.
- 2 unrelated tests fail locally (test_ogc_wells_items_and_item,
test_ogc_project_areas_items_expose_groups_with_project_areas).
Confirmed they fail the same way even without my change — leftover data
in the local test database, not caused by this fix.
---
README.md | 4 ++--
core/pygeoapi.py | 1 +
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 7a1248d1c..656d47556 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@ curl http://localhost:8000/ogcapi/collections/locations
```bash
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/water_wells/items?limit=5"
curl "http://localhost:8000/ogcapi/collections/springs/items?limit=5"
curl "http://localhost:8000/ogcapi/collections/locations/items/123"
```
@@ -56,7 +56,7 @@ curl "http://localhost:8000/ogcapi/collections/locations/items/123"
```bash
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"
+curl "http://localhost:8000/ogcapi/collections/water_wells/items?datetime=2020-01-01/2024-01-01"
```
### Polygon filter (CQL2 text)
diff --git a/core/pygeoapi.py b/core/pygeoapi.py
index 7a3a35126..377d77c0f 100644
--- a/core/pygeoapi.py
+++ b/core/pygeoapi.py
@@ -267,6 +267,7 @@ def _thing_collections_block(
"id_field": "id",
"table": f"{table_prefix}{collection['id']}",
"geom_field": "point",
+ "time_field": "first_visit_date",
}
],
}
From 88634304f44b8a445f60b8e909e356f5c1f73080 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Thu, 13 Aug 2026 16:45:44 -0700
Subject: [PATCH 048/151] fix(edr): source water-chemistry EDR from the legacy
NMA tables
The water_chemistry EDR collection is advertised in /ogcapi/collections and
returns an empty FeatureCollection on staging. The views behind it
(z9a0b1c2d3e4, mirrored by 2d3c3a268652) read the normalized chain --
observation -> sample -> field_activity -> field_event -> thing -- and nothing
populates that chain with analyte data. Per docs/chemistry-ingestion-runbook.md
the live ingestion path writes only to the legacy NMA_* tables, which is why
ogc_major_chemistry_results and ogc_minor_chemistry_wells serve thousands of
rows from the same database.
This repoints both EDR chemistry views at the legacy tables at the per-result
grain EDR needs, unioning the four families that hang off
NMA_Chemistry_SampleInfo: major, minor/trace, radionuclides, and field
parameters. Field parameters carry no analysis date of their own and ride on
the sample's CollectionDate; rows that end up with no timestamp are dropped,
since EDR needs a time axis.
Interim by design. When chemistry reaches the normalized model the views move
back and the EDR contract does not change -- same collection, same
parameter-names, same CoverageJSON. downgrade() restores the normalized
definitions by importing them from the revisions that own them rather than
copying, so they cannot drift.
Three deliberate differences from the pivot views, documented in the revision:
no thing_type filter (chemistry at a spring is still chemistry); publication
gated on thing.release_status plus NMA_Chemistry_SampleInfo."PublicRelease" not
being explicitly false; and parameter_name taken as raw trimmed analyte text
rather than canonicalized, which leaves ADR3's chemistry-cardinality question
open but reachable.
Verified against ocotilloapi_test with seeded rows across all four families:
public view returns the public well and the spring, excludes a draft thing and
a PublicRelease = false sample, and drops a NULL-analyte row; internal mirror
returns those two extra rows. The provider's own queries (_read projection,
get_fields DISTINCT, bbox/WKT/datetime predicates) all run against the view,
and a downgrade/upgrade cycle restores each definition.
Co-Authored-By: Claude Opus 5
---
..._edr_water_chemistry_from_legacy_tables.py | 287 ++++++++++++++++++
1 file changed, 287 insertions(+)
create mode 100644 alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py
diff --git a/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py b/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py
new file mode 100644
index 000000000..2d6c6e1e0
--- /dev/null
+++ b/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py
@@ -0,0 +1,287 @@
+"""rebuild the EDR water-chemistry views on the legacy NMA chemistry tables
+
+ogc_water_chemistry (z9a0b1c2d3e4) and its internal mirror (2d3c3a268652) read
+the normalized chain -- observation -> sample -> field_activity -> field_event
+-> thing. Nothing populates that chain with analyte data: per
+docs/chemistry-ingestion-runbook.md, the live ingestion path
+(services/chemistry_lims.py, services/chemistry_drive.py, `oco water-chemistry
+bulk-upload`) writes only to the legacy NMA_* tables. So the EDR collection is
+advertised in /ogcapi/collections and returns an empty FeatureCollection, while
+ogc_major_chemistry_results and ogc_minor_chemistry_wells -- both built on the
+same legacy tables -- serve thousands of rows.
+
+This revision repoints both EDR chemistry views at the legacy tables, at the
+per-result grain EDR needs (one row per analyte measurement, not the per-well
+summary the pivot views produce). Four families are unioned, all sharing the
+same shape via NMA_Chemistry_SampleInfo:
+
+ NMA_MajorChemistry "Analyte"/"Symbol", "SampleValue", "Units"
+ NMA_MinorTraceChemistry analyte/symbol, sample_value, units
+ NMA_Radionuclides "Analyte"/"Symbol", "SampleValue", "Units"
+ NMA_FieldParameters "FieldParameter", "SampleValue", "Units"
+
+This is interim. When chemistry lands in the normalized Sample/Observation
+model, the views move back and the EDR contract does not change -- consumers
+see the same collection, parameter-names, and CoverageJSON either way.
+
+Three deliberate differences from the pivot views, each of which would
+otherwise be a silent surprise:
+
+* No thing_type filter. ogc_major_chemistry_results restricts to
+ thing_type = 'water well' because it is a wells layer; this is a chemistry
+ collection, so chemistry collected at a spring belongs in it.
+* Publication is gated on thing.release_status = 'public' (the convention
+ f4a5b6c7d8e9 established for the legacy-backed views) AND on
+ NMA_Chemistry_SampleInfo."PublicRelease" not being explicitly false. The
+ pivot views ignore PublicRelease; honouring it here errs toward
+ withholding, and NULL is treated as "not suppressed" so the two layers stay
+ consistent on the rows that carry no opinion.
+* parameter_name is the raw trimmed legacy analyte text, falling back to the
+ symbol. The pivot views canonicalize analytes through long CASE blocks, but
+ those cover only the subset they expose as columns. Raw text keeps every
+ analyte reachable at the cost of aliases appearing as separate
+ parameter-names ("Ca" and "Calcium" both surface). That is ADR3's open
+ "chemistry parameter cardinality" question; canonicalizing is follow-up work
+ and changes only the parameter-name vocabulary, not this plumbing.
+
+Rows without a usable timestamp are dropped: EDR needs a time axis, and
+COALESCE(analysis date, collection date) is the best available. Field
+parameters carry no analysis date of their own, so they ride on the sample's
+CollectionDate.
+
+Revision ID: d9e0f1a2b3c4
+Revises: b7c8d9e0f1a2
+Create Date: 2026-08-13 15:40:00.000000
+"""
+
+import importlib.util
+from pathlib import Path
+from typing import Sequence, Union
+
+from alembic import op
+from sqlalchemy import inspect, text
+
+# revision identifiers, used by Alembic.
+revision: str = "d9e0f1a2b3c4"
+down_revision: Union[str, Sequence[str], None] = "b7c8d9e0f1a2"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+REQUIRED_TABLES = {
+ "NMA_Chemistry_SampleInfo",
+ "NMA_MajorChemistry",
+ "NMA_MinorTraceChemistry",
+ "NMA_Radionuclides",
+ "NMA_FieldParameters",
+ "thing",
+ "location",
+ "location_thing_association",
+}
+
+PUBLIC_VIEW = "ogc_water_chemistry"
+INTERNAL_VIEW = "ogc_internal_water_chemistry"
+
+VIEW_COMMENTS = {
+ PUBLIC_VIEW: (
+ "Public water-chemistry analyses (by analyte) for EDR, sourced from "
+ "the legacy NMA chemistry tables."
+ ),
+ INTERNAL_VIEW: (
+ "All water-chemistry analyses (by analyte) for internal EDR, sourced "
+ "from the legacy NMA chemistry tables."
+ ),
+}
+
+# Same latest-location shape the other ogc_* views use (d5e6f7a8b9c0).
+_LATEST_LOCATION_CTE = """
+ SELECT DISTINCT ON (lta.thing_id)
+ lta.thing_id,
+ lta.location_id,
+ lta.effective_start
+ FROM location_thing_association AS lta
+ WHERE lta.effective_end IS NULL
+ ORDER BY lta.thing_id, lta.effective_start DESC
+"""
+
+
+def _result_family(
+ *,
+ id_prefix: str,
+ table: str,
+ analyte_column: str,
+ value_column: str,
+ unit_column: str,
+ date_column: str | None,
+) -> str:
+ """One SELECT over a legacy chemistry table, normalized to a common shape.
+
+ ``date_column`` is None for NMA_FieldParameters, which has no analysis
+ date of its own and falls back to the sample's CollectionDate.
+ """
+ observed_at = (
+ f'COALESCE(r.{date_column}, csi."CollectionDate")'
+ if date_column
+ else 'csi."CollectionDate"'
+ )
+ return f"""
+ SELECT
+ '{id_prefix}-' || r.id AS id,
+ csi.id AS sample_id,
+ csi.thing_id AS thing_id,
+ csi."PublicRelease" AS sample_public_release,
+ {observed_at} AS datetime,
+ r.{value_column}::double precision AS value,
+ r.{unit_column} AS unit,
+ NULLIF(trim({analyte_column}), '') AS parameter_name
+ FROM "{table}" AS r
+ JOIN "NMA_Chemistry_SampleInfo" AS csi
+ ON csi.id = r.chemistry_sample_info_id
+ WHERE r.{value_column} IS NOT NULL
+ """
+
+
+def _result_families() -> str:
+ families = [
+ _result_family(
+ id_prefix="maj",
+ table="NMA_MajorChemistry",
+ analyte_column='COALESCE(r."Analyte", r."Symbol")',
+ value_column='"SampleValue"',
+ unit_column='"Units"',
+ date_column='"AnalysisDate"',
+ ),
+ _result_family(
+ id_prefix="min",
+ table="NMA_MinorTraceChemistry",
+ analyte_column="COALESCE(r.analyte, r.symbol)",
+ value_column="sample_value",
+ unit_column="units",
+ date_column="analysis_date",
+ ),
+ _result_family(
+ id_prefix="rad",
+ table="NMA_Radionuclides",
+ analyte_column='COALESCE(r."Analyte", r."Symbol")',
+ value_column='"SampleValue"',
+ unit_column='"Units"',
+ date_column='"AnalysisDate"',
+ ),
+ _result_family(
+ id_prefix="fld",
+ table="NMA_FieldParameters",
+ analyte_column='r."FieldParameter"',
+ value_column='"SampleValue"',
+ unit_column='"Units"',
+ date_column=None,
+ ),
+ ]
+ return "\n UNION ALL\n".join(families)
+
+
+def _create_water_chemistry_view(view_name: str, public_only: bool) -> str:
+ release_filter = (
+ """
+ AND t.release_status = 'public'
+ AND results.sample_public_release IS NOT FALSE"""
+ if public_only
+ else ""
+ )
+ return f"""
+ CREATE VIEW {view_name} AS
+ WITH latest_location AS (
+ {_LATEST_LOCATION_CTE}
+ ),
+ results AS (
+ {_result_families()}
+ )
+ SELECT
+ results.id AS id,
+ t.id AS thing_id,
+ t.name AS station_name,
+ ST_X(l.point) AS longitude,
+ ST_Y(l.point) AS latitude,
+ results.datetime AS datetime,
+ results.value AS value,
+ results.unit AS unit,
+ results.parameter_name AS parameter_name,
+ results.sample_id AS sample_id,
+ t.release_status AS release_status
+ FROM results
+ JOIN thing AS t ON t.id = results.thing_id
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE results.parameter_name IS NOT NULL
+ AND results.datetime IS NOT NULL{release_filter}
+ """
+
+
+def _load_revision_module(filename: str, module_name: str):
+ path = Path(__file__).with_name(filename)
+ if not path.exists():
+ raise RuntimeError(
+ f"Cannot restore the previous EDR chemistry views: {filename} is "
+ "missing from alembic/versions."
+ )
+ spec = importlib.util.spec_from_file_location(module_name, path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _drop_view_or_materialized_view(view_name: str) -> None:
+ # DROP VIEW IF EXISTS only suppresses "relation does not exist" -- Postgres
+ # still raises WrongObjectType if the relation is a materialized view, so
+ # check the actual kind first.
+ bind = op.get_bind()
+ relkind = bind.execute(
+ text("SELECT relkind FROM pg_class WHERE oid = to_regclass(:name)"),
+ {"name": view_name},
+ ).scalar()
+ if relkind == "m":
+ op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {view_name}"))
+ elif relkind == "v":
+ op.execute(text(f"DROP VIEW IF EXISTS {view_name}"))
+
+
+def _check_required_tables() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ existing = set(inspector.get_table_names(schema="public"))
+ missing = REQUIRED_TABLES - existing
+ if missing:
+ raise RuntimeError(
+ "Cannot rebuild the EDR water-chemistry views. Missing required "
+ f"tables: {sorted(missing)}"
+ )
+
+
+def upgrade() -> None:
+ _check_required_tables()
+
+ for view_name, public_only in ((PUBLIC_VIEW, True), (INTERNAL_VIEW, False)):
+ _drop_view_or_materialized_view(view_name)
+ op.execute(text(_create_water_chemistry_view(view_name, public_only)))
+ op.execute(text(f"COMMENT ON VIEW {view_name} IS '{VIEW_COMMENTS[view_name]}'"))
+
+
+def downgrade() -> None:
+ # Restore the normalized-model definitions from the revisions that own
+ # them, rather than a copy that could drift from those files.
+ edr = _load_revision_module(
+ "z9a0b1c2d3e4_add_edr_water_views.py", "_edr_water_views"
+ )
+ internal = _load_revision_module(
+ "2d3c3a268652_create_internal_ogc_views.py", "_internal_ogc_views"
+ )
+
+ _drop_view_or_materialized_view(PUBLIC_VIEW)
+ op.execute(text(edr._create_water_chemistry_view()))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_water_chemistry IS "
+ "'Public water-chemistry analyses (by analyte) for EDR.'"
+ )
+ )
+
+ _drop_view_or_materialized_view(INTERNAL_VIEW)
+ op.execute(text(internal._create_internal_water_chemistry_view()))
From aac3d8714512c840b6930d640d54e0fbd4e0a586 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Thu, 13 Aug 2026 16:58:02 -0700
Subject: [PATCH 049/151] fix(edr): expose thing_type and materialize the
chemistry coverages
Two follow-ups to the legacy-backed chemistry views.
thing_type is now a column on both views and, when the backing relation has
it, a property on the /locations features the provider returns. Since these
views deliberately carry no thing_type filter -- chemistry collected at a
spring is still chemistry -- a consumer otherwise had no way to tell a well
from a spring. ogc_waterlevels has no such column; the provider detects it
rather than assuming, so that collection is untouched.
Both views become MATERIALIZED, matching ogc_major_chemistry_results and
ogc_minor_chemistry_wells. As plain views, every request re-planned a four-way
UNION over the full legacy result tables, and get_fields() runs SELECT DISTINCT
parameter_name, unit at provider construction -- a full scan per request. The
staging and production tables are already well past the point where that is
affordable. Indexes cover the provider's three filter columns, and the unique
index on id allows CONCURRENTLY refreshes.
Freshness now matches the other chemistry layers: the nightly pg_cron job
discovers materialized views from the catalog, and both are registered in
services/materialized_views.py for `oco refresh-materialized-views` after an
ad-hoc ingestion.
Column detection reads pg_attribute, not information_schema.columns, which
does not list materialized views -- detection silently returned False against
the materialized views until this was caught end-to-end.
Verified against ocotilloapi_test: both relations are relkind 'm' with the
four expected indexes; CONCURRENTLY refresh succeeds and picks up new rows;
the provider reports thing_type on the chemistry matview, omits it on the
ogc_waterlevels view, and returns False rather than raising for a missing
relation.
Co-Authored-By: Claude Opus 5
---
..._edr_water_chemistry_from_legacy_tables.py | 49 +++++++++++++++++--
core/edr_provider.py | 38 +++++++++++++-
services/materialized_views.py | 4 ++
tests/test_cli_commands.py | 10 ++--
tests/test_edr_provider.py | 32 ++++++++++++
5 files changed, 125 insertions(+), 8 deletions(-)
create mode 100644 tests/test_edr_provider.py
diff --git a/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py b/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py
index 2d6c6e1e0..1cd253665 100644
--- a/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py
+++ b/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py
@@ -29,7 +29,10 @@
* No thing_type filter. ogc_major_chemistry_results restricts to
thing_type = 'water well' because it is a wells layer; this is a chemistry
- collection, so chemistry collected at a spring belongs in it.
+ collection, so chemistry collected at a spring belongs in it. thing_type is
+ carried as a column instead, so a consumer can tell a well from a spring
+ rather than having the distinction silently dropped -- the EDR provider
+ surfaces it on /locations features when the backing view has the column.
* Publication is gated on thing.release_status = 'public' (the convention
f4a5b6c7d8e9 established for the legacy-backed views) AND on
NMA_Chemistry_SampleInfo."PublicRelease" not being explicitly false. The
@@ -49,6 +52,21 @@
parameters carry no analysis date of their own, so they ride on the sample's
CollectionDate.
+Both are MATERIALIZED views, matching ogc_major_chemistry_results and
+ogc_minor_chemistry_wells. A plain view would be re-planned on every request
+across a four-way UNION of the full legacy result tables, and the provider's
+get_fields() runs SELECT DISTINCT parameter_name, unit at provider
+construction -- a full scan per request, against tables that already hold far
+more than the pivot views' per-well row counts suggest. Indexes cover the
+provider's three filter columns (thing_id, datetime, parameter_name), and the
+unique index on id is what allows CONCURRENTLY refreshes.
+
+The cost is staleness: the nightly pg_cron job discovers every materialized
+view from the catalog (x2y3z4a5b6c7), so these refresh with the rest, and
+services/materialized_views.py lists them for `oco refresh-materialized-views`
+after an ad-hoc chemistry ingestion. That is the same freshness contract the
+existing chemistry layers already have.
+
Revision ID: d9e0f1a2b3c4
Revises: b7c8d9e0f1a2
Create Date: 2026-08-13 15:40:00.000000
@@ -187,7 +205,7 @@ def _create_water_chemistry_view(view_name: str, public_only: bool) -> str:
else ""
)
return f"""
- CREATE VIEW {view_name} AS
+ CREATE MATERIALIZED VIEW {view_name} AS
WITH latest_location AS (
{_LATEST_LOCATION_CTE}
),
@@ -198,6 +216,7 @@ def _create_water_chemistry_view(view_name: str, public_only: bool) -> str:
results.id AS id,
t.id AS thing_id,
t.name AS station_name,
+ t.thing_type AS thing_type,
ST_X(l.point) AS longitude,
ST_Y(l.point) AS latitude,
results.datetime AS datetime,
@@ -255,13 +274,37 @@ def _check_required_tables() -> None:
)
+def _create_indexes(view_name: str) -> None:
+ # The unique index is what lets REFRESH MATERIALIZED VIEW CONCURRENTLY run
+ # (`oco refresh-materialized-views --concurrently`); Postgres refuses
+ # without one. id is unique by construction -- each family prefixes its own
+ # primary key.
+ op.execute(text(f"CREATE UNIQUE INDEX ux_{view_name}_id ON {view_name} (id)"))
+ # The provider filters on thing_id (locations / position), datetime
+ # (interval), and parameter_name (parameter-name), so each gets an index.
+ op.execute(text(f"CREATE INDEX ix_{view_name}_thing_id ON {view_name} (thing_id)"))
+ op.execute(text(f"CREATE INDEX ix_{view_name}_datetime ON {view_name} (datetime)"))
+ op.execute(
+ text(
+ f"CREATE INDEX ix_{view_name}_parameter_name "
+ f"ON {view_name} (parameter_name)"
+ )
+ )
+
+
def upgrade() -> None:
_check_required_tables()
for view_name, public_only in ((PUBLIC_VIEW, True), (INTERNAL_VIEW, False)):
_drop_view_or_materialized_view(view_name)
op.execute(text(_create_water_chemistry_view(view_name, public_only)))
- op.execute(text(f"COMMENT ON VIEW {view_name} IS '{VIEW_COMMENTS[view_name]}'"))
+ _create_indexes(view_name)
+ op.execute(
+ text(
+ f"COMMENT ON MATERIALIZED VIEW {view_name} IS "
+ f"'{VIEW_COMMENTS[view_name]}'"
+ )
+ )
def downgrade() -> None:
diff --git a/core/edr_provider.py b/core/edr_provider.py
index db377af5b..35815d089 100644
--- a/core/edr_provider.py
+++ b/core/edr_provider.py
@@ -95,6 +95,12 @@ def __init__(self, provider_def):
self._fields = {}
self.get_fields()
+ # Station metadata carried by some backing views but not others: the
+ # chemistry views (d9e0f1a2b3c4) span wells and springs and expose
+ # thing_type so a consumer can tell them apart. Detected rather than
+ # assumed, so a view without the column keeps working unchanged.
+ self._has_thing_type = self._has_column("thing_type")
+
# ------------------------------------------------------------------ db
def _connect(self):
try:
@@ -117,6 +123,25 @@ def _fetch(self, sql, params=None):
if conn is not None:
conn.close()
+ def _has_column(self, column):
+ """Whether the backing relation exposes ``column``.
+
+ Reads pg_attribute rather than information_schema.columns: the
+ chemistry collections are backed by materialized views, which
+ information_schema does not list at all.
+ """
+ try:
+ rows = self._fetch(
+ "SELECT 1 FROM pg_attribute "
+ "WHERE attrelid = to_regclass(%s) AND attname = %s "
+ "AND attnum > 0 AND NOT attisdropped LIMIT 1",
+ [self.table, column],
+ )
+ except ProviderConnectionError:
+ # View may not exist yet (e.g. OpenAPI generation before migrate).
+ return False
+ return bool(rows)
+
# -------------------------------------------------------------- fields
def get_fields(self):
"""Return the parameter-name fields present in the backing view."""
@@ -192,8 +217,11 @@ def locations(
bbox=bbox,
)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
+ columns = "thing_id, station_name, longitude, latitude"
+ if self._has_thing_type:
+ columns += ", thing_type"
rows = self._fetch(
- f"SELECT DISTINCT thing_id, station_name, longitude, latitude " # noqa: S608
+ f"SELECT DISTINCT {columns} " # noqa: S608 (trusted table/columns)
f"FROM {self.table}{where} ORDER BY thing_id",
params,
)
@@ -207,12 +235,18 @@ def locations(
"type": "Point",
"coordinates": [row["longitude"], row["latitude"]],
},
- "properties": {"name": row["station_name"]},
+ "properties": self._station_properties(row),
}
for row in rows
],
}
+ def _station_properties(self, row):
+ properties = {"name": row["station_name"]}
+ if self._has_thing_type:
+ properties["thing_type"] = row["thing_type"]
+ return properties
+
def area(
self, wkt=None, select_properties=None, datetime_=None, instance=None, **kwargs
):
diff --git a/services/materialized_views.py b/services/materialized_views.py
index ec1ae7103..9b7e3740e 100644
--- a/services/materialized_views.py
+++ b/services/materialized_views.py
@@ -15,5 +15,9 @@
"ogc_water_well_summary",
"ogc_major_chemistry_results",
"ogc_minor_chemistry_wells",
+ # EDR chemistry coverages (d9e0f1a2b3c4). Same legacy source tables as the
+ # two pivot views above, at per-result grain.
+ "ogc_water_chemistry",
+ "ogc_internal_water_chemistry",
"transducer_daily_data",
)
diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py
index f64a81306..cc583b074 100644
--- a/tests/test_cli_commands.py
+++ b/tests/test_cli_commands.py
@@ -70,10 +70,12 @@ def __exit__(self, exc_type, exc, tb):
"REFRESH MATERIALIZED VIEW ogc_water_well_summary",
"REFRESH MATERIALIZED VIEW ogc_major_chemistry_results",
"REFRESH MATERIALIZED VIEW ogc_minor_chemistry_wells",
+ "REFRESH MATERIALIZED VIEW ogc_water_chemistry",
+ "REFRESH MATERIALIZED VIEW ogc_internal_water_chemistry",
"REFRESH MATERIALIZED VIEW transducer_daily_data",
]
assert commit_called["value"] is True
- assert "Refreshed 8 materialized view(s)." in result.output
+ assert "Refreshed 10 materialized view(s)." in result.output
def test_refresh_materialized_views_custom_and_concurrently(
@@ -702,10 +704,12 @@ def _write_csv(path: Path, *, well_name: str, notes: str):
"Water level accurate to within two hundreths of a foot,"
f"{notes}"
)
- csv_text = textwrap.dedent(f"""\
+ csv_text = textwrap.dedent(
+ f"""\
{header}
{row}
- """)
+ """
+ )
path.write_text(csv_text)
unique_notes = f"pytest-{uuid.uuid4()}"
diff --git a/tests/test_edr_provider.py b/tests/test_edr_provider.py
new file mode 100644
index 000000000..f7ba87dcc
--- /dev/null
+++ b/tests/test_edr_provider.py
@@ -0,0 +1,32 @@
+"""Unit tests for the EDR provider's optional station metadata.
+
+The chemistry views (d9e0f1a2b3c4) carry thing_type because they span wells and
+springs; ogc_waterlevels does not. The provider detects the column rather than
+assuming it, so these cover both shapes without needing a database.
+"""
+
+from core.edr_provider import WaterEDRProvider
+
+
+def _provider(has_thing_type: bool) -> WaterEDRProvider:
+ # Bypass __init__: it connects to Postgres to read fields and detect
+ # columns, and neither is what these tests are about.
+ provider = object.__new__(WaterEDRProvider)
+ provider._has_thing_type = has_thing_type
+ return provider
+
+
+def test_station_properties_includes_thing_type_when_the_view_has_it():
+ properties = _provider(True)._station_properties(
+ {"station_name": "NM-28368", "thing_type": "spring"}
+ )
+
+ assert properties == {"name": "NM-28368", "thing_type": "spring"}
+
+
+def test_station_properties_omits_thing_type_when_the_view_lacks_it():
+ # ogc_waterlevels has no thing_type column, so the row has no such key --
+ # reading it unconditionally would raise instead of degrading.
+ properties = _provider(False)._station_properties({"station_name": "NM-28368"})
+
+ assert properties == {"name": "NM-28368"}
From a102ad480c0e3f3599a50948b4d5d16f285e3b44 Mon Sep 17 00:00:00 2001
From: jirhiker <2035568+jirhiker@users.noreply.github.com>
Date: Thu, 13 Aug 2026 23:58:34 +0000
Subject: [PATCH 050/151] Formatting changes
---
tests/test_cli_commands.py | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py
index cc583b074..97534a601 100644
--- a/tests/test_cli_commands.py
+++ b/tests/test_cli_commands.py
@@ -704,12 +704,10 @@ def _write_csv(path: Path, *, well_name: str, notes: str):
"Water level accurate to within two hundreths of a foot,"
f"{notes}"
)
- csv_text = textwrap.dedent(
- f"""\
+ csv_text = textwrap.dedent(f"""\
{header}
{row}
- """
- )
+ """)
path.write_text(csv_text)
unique_notes = f"pytest-{uuid.uuid4()}"
From b6887b218e91130461b2e9ba9216e5e8da374c05 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 17 Aug 2026 15:07:47 +0000
Subject: [PATCH 051/151] build(deps): bump astral-sh/setup-uv from 9.0.0 to
10.0.1
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 9.0.0 to 10.0.1.
- [Commits](https://github.com/astral-sh/setup-uv/compare/v9.0.0...v10.0.1)
---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
dependency-version: 10.0.1
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
---
.github/workflows/CD_production.yml | 2 +-
.github/workflows/CD_staging.yml | 2 +-
.github/workflows/CD_testing.yml | 2 +-
.github/workflows/data_migrations.yml | 2 +-
.github/workflows/forward-merge.yml | 4 ++--
.github/workflows/jira_codex_pr.yml | 2 +-
.github/workflows/tests.yml | 4 ++--
7 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml
index 39c5ff6b4..c5e1e8b2d 100644
--- a/.github/workflows/CD_production.yml
+++ b/.github/workflows/CD_production.yml
@@ -54,7 +54,7 @@ jobs:
ref: refs/tags/${{ env.DEPLOY_TAG }}
- name: Install uv in container
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
version: "latest"
diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml
index c1cbd243d..fa42b00da 100644
--- a/.github/workflows/CD_staging.yml
+++ b/.github/workflows/CD_staging.yml
@@ -19,7 +19,7 @@ jobs:
fetch-depth: 0
- name: Install uv in container
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
version: "latest"
diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml
index 0ed101d15..920ad98c9 100644
--- a/.github/workflows/CD_testing.yml
+++ b/.github/workflows/CD_testing.yml
@@ -19,7 +19,7 @@ jobs:
fetch-depth: 0
- name: Install uv in container
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
version: "latest"
diff --git a/.github/workflows/data_migrations.yml b/.github/workflows/data_migrations.yml
index b8af98622..32def6fd7 100644
--- a/.github/workflows/data_migrations.yml
+++ b/.github/workflows/data_migrations.yml
@@ -72,7 +72,7 @@ jobs:
uses: actions/checkout@v7.0.1
- name: Install uv in container
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
version: "latest"
diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml
index ca10accf3..8b36722d2 100644
--- a/.github/workflows/forward-merge.yml
+++ b/.github/workflows/forward-merge.yml
@@ -103,7 +103,7 @@ jobs:
# the lockfile is re-locked (see commit 27751110). Idempotent: no
# lockfile change -> no commit.
- name: Install uv
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true
cache-dependency-glob: uv.lock
@@ -166,7 +166,7 @@ jobs:
# push. Plain push (not force) so an out-of-date checkout fails loudly
# instead of clobbering newer hotfix commits.
- name: Install uv
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true
cache-dependency-glob: uv.lock
diff --git a/.github/workflows/jira_codex_pr.yml b/.github/workflows/jira_codex_pr.yml
index 149dfc60e..f352b7410 100644
--- a/.github/workflows/jira_codex_pr.yml
+++ b/.github/workflows/jira_codex_pr.yml
@@ -59,7 +59,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Set up uv (with cache)
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v4
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v4
with:
enable-cache: true
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 9f8b0fcb0..7aea89d11 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -62,7 +62,7 @@ jobs:
exit 1
- name: Install uv
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true
cache-dependency-glob: uv.lock
@@ -153,7 +153,7 @@ jobs:
exit 1
- name: Install uv
- uses: astral-sh/setup-uv@v9.0.0
+ uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true
cache-dependency-glob: uv.lock
From e7e89ba7f94d38d098c766ca3f24e63ac123bcdc Mon Sep 17 00:00:00 2001
From: jakeross
Date: Mon, 17 Aug 2026 09:33:22 -0700
Subject: [PATCH 052/151] chore(auth): harden Authentik authorization
Six related fixes to the authorization layer, plus two defects surfaced
while adding coverage for it.
Fail fast on a misconfigured bypass. assert_auth_configuration() runs at
app creation and refuses to boot when AUTHENTIK_DISABLE_AUTHENTICATION=1
outside MODE=development. The previous guard was per-request and keyed on
MODE == "production", so a deploy with MODE unset served every endpoint
anonymously and logged nothing. Collapses the two disagreeing reads of
that variable (an import-time snapshot governing JWKS, a per-request read
governing enforcement) into authentication_disabled(), and makes
settings.mode a fresh env read so it no longer depends on which module
called load_dotenv() first.
Enforce the role hierarchy in code. authenticated() gains any_of=, and
the tiers in core/dependencies.py list every group that satisfies them,
so Admin satisfies an editor- or viewer-gated route. Previously the
documented Admin > Editor > Viewer hierarchy existed nowhere in code and
held only as long as whoever provisioned Authentik granted all three
tiers; an Admin without the Viewer group was denied on every read.
Families stay orthogonal -- general Admin confers nothing in AMP or
Lexicon.
Rename @public_route to @in_public_schema. It only ever controlled
anonymous OpenAPI visibility, but read like an authorization decorator:
two /thing routes carried it alongside a viewer_dependency. Removed from
those, added to the routes that genuinely have no dependency (/ngwmn/*,
polled by the federal NGWMN harvester, and /disclaimer, advertised as
terms_of_service by both pygeoapi mounts).
Verify the iss claim against AUTHENTIK_URL, accepting both trailing-slash
spellings. Drop the dead scope= parameter, which was unused and would
have substring-matched had it been used, since the OIDC scope claim is a
space-delimited string rather than a list. Share one _decode() helper
between the Depends path and the internal-OGC ASGI middleware, so a
request decodes its token once instead of twice.
Give the JWKS cache a TTL (AUTHENTIK_JWKS_TTL_SECONDS, default 3600) and
force one refresh on an unrecognized kid. It was an unbounded lru_cache,
so an Authentik key rotation 401'd every request until the process was
redeployed.
Insufficient groups now returns 403 rather than 401. This matters to
OcotilloUI, whose axios-auth-refresh interceptor fires on 401 only: the
old status sent an under-permissioned user through a token refresh and
retry, and on to a forced logout.
Two defects the new tests caught:
POST /feedback was fully unauthenticated. It declared
`_user=viewer_dependency` -- the dependency alias as a default value
rather than a type annotation -- so FastAPI treated _user as a query
parameter and never ran the dependency. Anyone could post arbitrary
content that the server relays into Jira and Slack under its own
credentials.
public_openapi() could not see routes added via include_router. Those
live inside opaque _IncludedRouter branches rather than being flattened
into app.routes, so matching schema paths against app.routes by .path
found only endpoints declared directly on the app, and the public schema
contained /health alone. It now walks iter_route_contexts(), the same
helper get_openapi() uses.
tests/test_authorization.py covers all of it: an inventory test over
every route's dependency tree against an explicit allowlist of
intentionally anonymous routes (authorization is opt-in per endpoint, so
nothing else notices an omission), agreement between that set and the
anonymous OpenAPI schema, the group-membership logic, the startup guard,
and JWKS caching.
Co-Authored-By: Claude Opus 5
---
.env.example | 11 ++
CLAUDE.md | 23 +++
api/disclaimer.py | 2 +
api/feedback.py | 2 +-
api/ngwmn.py | 9 +
api/thing.py | 3 -
core/app.py | 55 ++++--
core/dependencies.py | 29 ++-
core/factory.py | 9 +
core/internal_ogc_auth.py | 14 +-
core/permissions.py | 265 +++++++++++++++++--------
core/settings.py | 14 +-
tests/test_authorization.py | 376 ++++++++++++++++++++++++++++++++++++
13 files changed, 687 insertions(+), 125 deletions(-)
create mode 100644 tests/test_authorization.py
diff --git a/.env.example b/.env.example
index 2e2bd4556..5fa1ad8ba 100644
--- a/.env.example
+++ b/.env.example
@@ -80,17 +80,28 @@ MODE=development
# ENABLE_PG_CRON=0
# disable authentication (for development only)
+#
+# Honored ONLY when MODE=development. With any other MODE (including unset or
+# "staging"), the app refuses to start -- core.permissions.assert_auth_configuration()
+# raises AuthConfigurationError rather than serving every endpoint anonymously.
AUTHENTIK_DISABLE_AUTHENTICATION=1
# erase and rebuild the database for step tests
REBUILD_DB=1
# authentik
+# AUTHENTIK_URL is both the JWKS base and the expected `iss` claim; trailing
+# slash optional, both spellings are accepted.
AUTHENTIK_URL=
AUTHENTIK_CLIENT_ID=
AUTHENTIK_AUTHORIZE_URL=
AUTHENTIK_TOKEN_URL=
+# How long a fetched JWKS document is trusted, in seconds (default 3600).
+# An unrecognized `kid` forces one immediate refresh regardless, so this only
+# bounds how long a revoked key stays usable.
+# AUTHENTIK_JWKS_TTL_SECONDS=3600
+
# feedback endpoint (POST /feedback) — bug reports and feature requests
JIRA_BASE_URL=https://nmbgmr.atlassian.net
diff --git a/CLAUDE.md b/CLAUDE.md
index 77cb84105..88802a2a0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -155,8 +155,31 @@ The system uses **Authentik** for OAuth2 authentication with role-based access c
- **Editor**: Can modify existing records (includes Viewer permissions)
- **Admin**: Can create new records (includes Editor + Viewer permissions)
+The hierarchy is enforced in code, via `authenticated(any_of=[...])` group lists —
+`Admin` satisfies an editor- or viewer-gated route without needing all three
+Authentik groups granted.
+
**AMP-Specific Roles**: `AMPAdmin`, `AMPEditor`, `AMPViewer` for legacy AMPAPI integration
+**Role families are orthogonal**: general `Admin` confers nothing in the AMP or
+Lexicon families. Only tiers *within* a family nest.
+
+**Authorization is opt-in per endpoint** — a `user: _dependency` parameter
+in the signature, not a router-level `dependencies=[...]`. Omitting it produces a
+fully public endpoint with no error. `tests/test_authorization.py` holds the
+allowlist of intentionally anonymous routes and fails on anything else. Note the
+annotation must be a *type annotation* (`user: viewer_dependency`), never a
+default value (`user=viewer_dependency`) — the latter silently disables the
+dependency and FastAPI treats it as a query parameter.
+
+**Development bypass**: `AUTHENTIK_DISABLE_AUTHENTICATION=1` is honored only when
+`MODE=development`. Any other `MODE` (including unset) makes
+`assert_auth_configuration()` abort startup.
+
+**`@in_public_schema`** (`core/app.py`) controls anonymous OpenAPI visibility
+only — it grants no access and removes no dependency. Apply it only to routes
+that genuinely have none.
+
### Database Configuration
The application supports two database modes (configured via `DB_DRIVER` in `.env`):
diff --git a/api/disclaimer.py b/api/disclaimer.py
index 2e94981d1..b0ec7e836 100644
--- a/api/disclaimer.py
+++ b/api/disclaimer.py
@@ -31,6 +31,7 @@
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
+from core.app import in_public_schema
from core.disclaimer import (
DISCLAIMER_CONTACT_EMAIL,
DISCLAIMER_PARAGRAPHS,
@@ -84,6 +85,7 @@ def _render_html() -> str:
)
+@in_public_schema
@router.get(
"/disclaimer",
response_class=HTMLResponse,
diff --git a/api/feedback.py b/api/feedback.py
index 68f632b2f..ce3d3473a 100644
--- a/api/feedback.py
+++ b/api/feedback.py
@@ -225,7 +225,7 @@ def _build_slack_payload(payload: FeedbackCreate, jira_key: str, jira_url: str)
@router.post("", response_model=FeedbackResponse)
async def create_feedback(
payload: FeedbackCreate,
- _user=viewer_dependency,
+ _user: viewer_dependency,
):
jira_base = os.environ["JIRA_BASE_URL"]
jira_email = os.environ["JIRA_EMAIL"]
diff --git a/api/ngwmn.py b/api/ngwmn.py
index 7fc2e1d51..c954fe788 100644
--- a/api/ngwmn.py
+++ b/api/ngwmn.py
@@ -16,6 +16,7 @@
from fastapi import APIRouter
from starlette.responses import Response
+from core.app import in_public_schema
from core.dependencies import session_dependency
from services.ngwmn_helper import (
make_waterlevels_response,
@@ -25,7 +26,13 @@
router = APIRouter(prefix="/ngwmn", tags=["NGWMN"])
+# These three routes are intentionally anonymous: the federal NGWMN harvester
+# polls them without credentials. @in_public_schema documents that (and lists
+# them in /openapi.json) so tests/test_authorization.py can tell an intentional
+# public route from an endpoint that simply forgot its `user:` dependency.
+
+@in_public_schema
@router.get(
"/waterlevels/{pointid}",
summary="Get waterlevels for a given pointid in the NGWMN format",
@@ -35,6 +42,7 @@ def read_ngwmn_waterlevels(pointid: str, db: session_dependency):
return Response(content=data, media_type="application/xml")
+@in_public_schema
@router.get(
"/wellconstruction/{pointid}",
summary="Get wellconstruction for a given pointid in the NGWMN format",
@@ -44,6 +52,7 @@ def read_ngwmn_wellconstruction(pointid: str, db: session_dependency):
return Response(content=data, media_type="application/xml")
+@in_public_schema
@router.get(
"/lithology/{pointid}",
summary="Get lithology for a given pointid in the NGWMN format",
diff --git a/api/thing.py b/api/thing.py
index baeed59e7..b1176071d 100644
--- a/api/thing.py
+++ b/api/thing.py
@@ -27,7 +27,6 @@
)
from api.pagination import CustomPage
-from core.app import public_route
from core.dependencies import (
session_dependency,
admin_dependency,
@@ -349,7 +348,6 @@ def get_thing_id_links(
return paginate(query=sql, conn=session)
-@public_route
@router.get("/id-link/{link_id}", summary="Get thing links by link ID")
def get_thing_id_links(
user: viewer_dependency,
@@ -362,7 +360,6 @@ def get_thing_id_links(
return simple_get_by_id(session, ThingIdLink, link_id)
-@public_route
@router.get("", summary="Get all things", status_code=HTTP_200_OK)
def get_things(
user: viewer_dependency,
diff --git a/core/app.py b/core/app.py
index d14ccecf3..dd392f08b 100644
--- a/core/app.py
+++ b/core/app.py
@@ -27,6 +27,7 @@
get_swagger_ui_oauth2_redirect_html,
)
from fastapi.openapi.utils import get_openapi
+from fastapi.routing import iter_route_contexts
from sqlalchemy import text
from sqlalchemy.orm import Session
@@ -123,27 +124,32 @@ def public_openapi():
routes=app.routes,
)
- # Keep only operations where the endpoint function is marked public.
+ # Collect the operations whose endpoint carries @in_public_schema.
+ #
+ # This walks iter_route_contexts() rather than app.routes. Routes added
+ # via app.include_router() are not flattened into app.routes -- they
+ # live inside opaque _IncludedRouter branches -- so the previous
+ # `next(r for r in app.routes if r.path == path)` lookup matched
+ # nothing but the few endpoints declared directly on `app`, and
+ # silently dropped every decorated router route from the public schema.
+ # iter_route_contexts() is the same helper get_openapi() itself walks,
+ # so prefixes resolve identically to the paths in `schema`.
+ public_operations = set()
+ for route_context in iter_route_contexts(app.routes):
+ if not getattr(route_context.endpoint, "_in_public_schema", False):
+ continue
+ route_path = route_context.path_format or route_context.path
+ for route_method in route_context.methods or ():
+ public_operations.add((route_path, route_method.lower()))
+
new_paths = {}
for path, path_item in schema["paths"].items():
new_methods = {}
for method, operation in path_item.items():
- route = next(
- (
- r
- for r in app.routes
- if getattr(r, "path", None) == path
- and method.upper() in getattr(r, "methods", set())
- ),
- None,
- )
- if not route:
+ if (path, method.lower()) not in public_operations:
continue
-
- endpoint = getattr(route, "endpoint", None)
- if getattr(endpoint, "_is_public", False):
- operation["security"] = []
- new_methods[method] = operation
+ operation["security"] = []
+ new_methods[method] = operation
if new_methods:
new_paths[path] = new_methods
@@ -224,7 +230,7 @@ async def warmup():
return {"status": "ok"}
@app.get("/health", tags=["meta"])
- @public_route
+ @in_public_schema
def health(response: Response, session: Session = Depends(get_db_session)):
# Ping the database so a 200 actually proves PostGIS is reachable, not
# just that the process is up. Uptime monitors / status pages assert on
@@ -248,9 +254,18 @@ def health(response: Response, session: Session = Depends(get_db_session)):
return app
-def public_route(func):
- """Mark a route as public for OpenAPI filtering."""
- setattr(func, "_is_public", True)
+def in_public_schema(func):
+ """Advertise a route in the anonymous OpenAPI schema (/openapi.json).
+
+ Schema visibility only -- this grants no access and removes no dependency.
+ It was previously named `public_route`, which read like an authorization
+ decorator; two `/thing` endpoints carried it *and* a `viewer_dependency`,
+ so the public schema advertised operations that 401 for anonymous callers.
+
+ Apply it only to routes that genuinely have no auth dependency.
+ tests/test_authorization.py asserts the two sets match exactly.
+ """
+ setattr(func, "_in_public_schema", True)
return func
diff --git a/core/dependencies.py b/core/dependencies.py
index 6372804a9..09e7c3f79 100644
--- a/core/dependencies.py
+++ b/core/dependencies.py
@@ -34,26 +34,35 @@
Admin, can do everything Editor and Viewer can do
+ create new objects
+That hierarchy is enforced here, by `any_of=` group lists rather than by
+Authentik group membership overlap: an Admin-only account satisfies an
+editor- or viewer-gated route because "Admin" appears in those lists. Before
+this was explicit, `authenticated(permissions=["Viewer"])` required the
+literal Viewer group, so the hierarchy held only as long as whoever
+provisioned the Authentik groups granted all three tiers to every admin.
+
+The three families below are deliberately orthogonal -- general `Admin` does
+not confer `AMPAdmin` or `LexiconAdmin`. Only tiers *within* a family nest.
"""
# General Purpose Authentication/Permissions -----------------------------------
-admin_function = authenticated(permissions=["Admin"])
-editor_function = authenticated(permissions=["Editor"])
-viewer_function = authenticated(permissions=["Viewer"])
+admin_function = authenticated(any_of=["Admin"])
+editor_function = authenticated(any_of=["Admin", "Editor"])
+viewer_function = authenticated(any_of=["Admin", "Editor", "Viewer"])
# AMP-Specific Authentication/Permissions --------------------------------------
-amp_admin_function = authenticated(permissions=["AMPAdmin"])
-amp_editor_function = authenticated(permissions=["AMPEditor"])
-amp_viewer_function = authenticated(permissions=["AMPViewer"])
+amp_admin_function = authenticated(any_of=["AMPAdmin"])
+amp_editor_function = authenticated(any_of=["AMPAdmin", "AMPEditor"])
+amp_viewer_function = authenticated(any_of=["AMPAdmin", "AMPEditor", "AMPViewer"])
# Lexicon-Specific Authentication/Permissions ----------------------------------
-lexicon_admin_function = authenticated(permissions=["LexiconAdmin"])
-lexicon_editor_function = authenticated(permissions=["LexiconEditor"])
+lexicon_admin_function = authenticated(any_of=["LexiconAdmin"])
+lexicon_editor_function = authenticated(any_of=["LexiconAdmin", "LexiconEditor"])
# OGC-Internal Authentication/Permissions --------------------------------------
@@ -63,7 +72,9 @@
# Testing-Specific Authentication/Permissions ----------------------------------
-no_permission_function = authenticated(permissions=["NoPermission"])
+# A group nobody is ever granted, so this dependency always 403s. Used to
+# assert that group enforcement is actually wired up.
+no_permission_function = authenticated(any_of=["NoPermission"])
# Permissions Dependencies -----------------------------------------------------
diff --git a/core/factory.py b/core/factory.py
index f85b6f4cf..79a347e73 100644
--- a/core/factory.py
+++ b/core/factory.py
@@ -40,6 +40,15 @@ def initialize_runtime() -> None:
def create_api_app():
initialize_runtime()
+
+ # After initialize_runtime()'s load_dotenv(), so MODE and
+ # AUTHENTIK_DISABLE_AUTHENTICATION are both resolved. Raises
+ # AuthConfigurationError -- boot fails loudly rather than serving every
+ # endpoint anonymously.
+ from core.permissions import assert_auth_configuration
+
+ assert_auth_configuration()
+
app = create_base_app()
register_api_routes(app)
from core.pygeoapi import mount_pygeoapi, mount_pygeoapi_internal
diff --git a/core/internal_ogc_auth.py b/core/internal_ogc_auth.py
index 8edd767cf..85f1ee3dd 100644
--- a/core/internal_ogc_auth.py
+++ b/core/internal_ogc_auth.py
@@ -29,7 +29,6 @@
"""
import json
-import os
from starlette.types import ASGIApp, Receive, Scope, Send
@@ -77,18 +76,15 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await self.app(scope, receive, send)
return
- if int(os.environ.get("AUTHENTIK_DISABLE_AUTHENTICATION", 0)):
- if settings.mode == "production":
+ if permissions.authentication_disabled():
+ if settings.mode != permissions.BYPASS_ALLOWED_MODE:
# HTTPException(424) (what core.permissions.authenticated()
# raises for this same misconfiguration) means nothing from
# raw ASGI code -- send the response directly so a
- # misconfigured production box degrades to "internal mount
- # always 424s" rather than crashing the worker.
+ # misconfigured box degrades to "internal mount always 424s"
+ # rather than crashing the worker.
await _send_json(
- send,
- 424,
- "Authentication is disabled in production mode. Set "
- "AUTHENTIK_DISABLE_AUTHENTICATION=0 to enable authentication.",
+ send, 424, permissions.bypass_misconfiguration_detail()
)
return
await self.app(scope, receive, send)
diff --git a/core/permissions.py b/core/permissions.py
index fec27d37f..ad3406f41 100644
--- a/core/permissions.py
+++ b/core/permissions.py
@@ -14,13 +14,13 @@
# limitations under the License.
# ===============================================================================
import os
-from functools import lru_cache
-from typing import Optional, List, Union, cast, Callable
+import threading
+import time
+from typing import Optional, List, Sequence, Tuple, Union, cast, Callable
import httpx
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, OAuth2AuthorizationCodeBearer
-from fastapi.security import OAuth2PasswordBearer
from jose import jwt
from jose.exceptions import JWTError
from jwt.algorithms import RSAAlgorithm
@@ -29,33 +29,133 @@
from core.settings import settings
-AUTHENTIK_ISSUER = os.environ.get("AUTHENTIK_URL")
ALGORITHMS = ["RS256"]
-jwks = {}
-auth_disabled = int(os.environ.get("AUTHENTIK_DISABLE_AUTHENTICATION", 0))
-if AUTHENTIK_ISSUER and not auth_disabled:
- JWKS_URL = f"{AUTHENTIK_ISSUER}jwks/"
+# The only MODE in which AUTHENTIK_DISABLE_AUTHENTICATION=1 is honored.
+# assert_auth_configuration() refuses to boot anywhere else, so a box with an
+# unset or mislabeled MODE can never come up with authentication switched off.
+BYPASS_ALLOWED_MODE = "development"
-@lru_cache(maxsize=1)
-def get_jwks():
- if not AUTHENTIK_ISSUER or auth_disabled:
+# How long a fetched JWKS document is trusted. Authentik rotates its signing
+# keys; the cache used to be an unbounded lru_cache, so a rotation 401'd every
+# request until the process was redeployed. get_public_key() also forces one
+# refresh on an unrecognized `kid`, which covers rotations inside the window.
+JWKS_TTL_SECONDS = int(os.environ.get("AUTHENTIK_JWKS_TTL_SECONDS", "3600"))
+
+
+def _issuer() -> str:
+ """Authentik issuer URL, read lazily so it survives late load_dotenv()."""
+ return (os.environ.get("AUTHENTIK_URL") or "").strip()
+
+
+def _accepted_issuers() -> Tuple[str, ...]:
+ """Issuer values accepted for the `iss` claim.
+
+ Authentik's issuer is the provider URL, which operators configure with or
+ without a trailing slash depending on where they copied it from. Accept
+ both spellings rather than making token validation depend on that.
+ """
+ issuer = _issuer()
+ if not issuer:
+ return ()
+ return (issuer.rstrip("/"), issuer.rstrip("/") + "/")
+
+
+def authentication_disabled() -> bool:
+ """Whether the development authentication bypass is switched on.
+
+ Read fresh from the environment on every call. This used to be an
+ import-time snapshot (`auth_disabled`) that the per-request check in
+ authenticated() did not share: flipping the variable after import left
+ JWKS fetching disabled while token verification stayed live, so every
+ request failed with "Invalid signing key" instead of either enforcing or
+ bypassing cleanly.
+ """
+ raw = (os.environ.get("AUTHENTIK_DISABLE_AUTHENTICATION") or "0").strip()
+ try:
+ return bool(int(raw))
+ except ValueError:
+ return raw.lower() in {"true", "yes", "on"}
+
+
+class AuthConfigurationError(RuntimeError):
+ """Raised at startup when the auth bypass is enabled outside development."""
+
+
+def bypass_misconfiguration_detail() -> str:
+ return (
+ "AUTHENTIK_DISABLE_AUTHENTICATION is enabled but MODE is "
+ f"{settings.mode or ''!r}. The bypass is only permitted when "
+ f"MODE={BYPASS_ALLOWED_MODE!r}. Set "
+ "AUTHENTIK_DISABLE_AUTHENTICATION=0, or set MODE=development."
+ )
+
+
+def assert_auth_configuration() -> None:
+ """Fail fast when the app is configured to serve traffic without auth.
+
+ Called from core.factory.create_api_app() after load_dotenv(). The old
+ guard was per-request and keyed on `settings.mode == "production"`, so a
+ deploy with MODE unset served every endpoint anonymously and logged
+ nothing. Two independent variables had to be right; now a wrong one stops
+ the process at boot.
+ """
+ if authentication_disabled() and settings.mode != BYPASS_ALLOWED_MODE:
+ raise AuthConfigurationError(bypass_misconfiguration_detail())
+
+
+_jwks_lock = threading.Lock()
+_jwks_cache: dict = {"payload": None, "fetched_at": 0.0}
+
+
+def reset_jwks_cache() -> None:
+ """Drop the cached JWKS. Test hook and manual-invalidation escape hatch."""
+ with _jwks_lock:
+ _jwks_cache["payload"] = None
+ _jwks_cache["fetched_at"] = 0.0
+
+
+def get_jwks(force_refresh: bool = False) -> dict:
+ if not _issuer() or authentication_disabled():
return {}
- resp = httpx.get(JWKS_URL, timeout=10.0)
+ if not force_refresh:
+ with _jwks_lock:
+ cached = _jwks_cache["payload"]
+ age = time.monotonic() - _jwks_cache["fetched_at"]
+ if cached is not None and age < JWKS_TTL_SECONDS:
+ return cached
+
+ resp = httpx.get(f"{_issuer().rstrip('/')}/jwks/", timeout=10.0)
resp.raise_for_status()
- return resp.json()
+ payload = resp.json()
+ with _jwks_lock:
+ _jwks_cache["payload"] = payload
+ _jwks_cache["fetched_at"] = time.monotonic()
+ return payload
-def get_public_key(token):
- unverified_header = jwt.get_unverified_header(token)
- for key in get_jwks().get("keys", []):
- if key["kid"] == unverified_header["kid"]:
- return RSAAlgorithm.from_jwk(key)
- raise HTTPException(status_code=401, detail="Invalid signing key")
+def _find_signing_key(jwks: dict, kid: Optional[str]) -> Optional[dict]:
+ if not kid:
+ return None
+ for key in jwks.get("keys", []):
+ if key.get("kid") == kid:
+ return key
+ return None
-oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
+
+def get_public_key(token):
+ kid = jwt.get_unverified_header(token).get("kid")
+
+ key = _find_signing_key(get_jwks(), kid)
+ if key is None:
+ # Unknown kid: Authentik may have rotated inside the TTL window.
+ # Refetch once before rejecting an otherwise valid token.
+ key = _find_signing_key(get_jwks(force_refresh=True), kid)
+ if key is None:
+ raise HTTPException(status_code=401, detail="Invalid signing key")
+ return RSAAlgorithm.from_jwk(key)
TokenType = Union[str, HTTPAuthorizationCredentials]
@@ -69,84 +169,93 @@ def get_public_key(token):
)
+def authorize_groups(
+ payload: dict,
+ require_all: Optional[Sequence[str]] = None,
+ require_any: Optional[Sequence[str]] = None,
+) -> bool:
+ """Check a decoded token's `groups` claim against a group requirement.
+
+ `require_all` demands every listed group; `require_any` demands at least
+ one. Role tiers in core/dependencies.py use `require_any` so that an Admin
+ satisfies an Editor- or Viewer-gated route. The old check was all-of only,
+ which meant the documented Admin > Editor > Viewer hierarchy existed
+ nowhere in code -- it worked solely because operators happened to grant
+ overlapping groups in Authentik, and an Admin without the Viewer group got
+ a 403 on every read.
+ """
+ groups = payload.get("groups") or []
+ if require_all and not all(group in groups for group in require_all):
+ return False
+ if require_any and not any(group in groups for group in require_any):
+ return False
+ return True
+
+
def authenticated(
optional: bool = False,
- scope: Optional[List[str]] = None,
permissions: Optional[List[str]] = None,
+ any_of: Optional[List[str]] = None,
):
+ """Build a FastAPI dependency enforcing a bearer token and group membership.
- def _authenicated(
+ `permissions` requires every listed Authentik group, `any_of` requires at
+ least one. Returns the decoded token payload on success so endpoints can
+ read claims; returns True when the development bypass is active.
+ """
+
+ def _authenticated(
request: Request,
response: Response,
token: TokenType = Depends(cast(Callable, scheme)),
):
- # def _authenicated(request: Request, response: Response):
- # def _authenicated():
- """
- A placeholder for the authentication logic.
- This function should check if the user is authenticated and has the required permissions.
- If `optional` is True, it should allow unauthenticated access.
- """
-
- if int(os.environ.get("AUTHENTIK_DISABLE_AUTHENTICATION", 0)):
- if settings.mode == "production":
+ if authentication_disabled():
+ # assert_auth_configuration() already rejected this combination at
+ # startup; this is the belt for a variable flipped at runtime.
+ if settings.mode != BYPASS_ALLOWED_MODE:
raise HTTPException(
status_code=status.HTTP_424_FAILED_DEPENDENCY,
- detail="Authentication is disabled in production mode. Set AUTHENTIK_DISABLE_AUTHENTICATION=0 to enable authentication.",
+ detail=bypass_misconfiguration_detail(),
)
return True
- if optional and not token:
- return True
-
- # Here you would typically check the token against your authentication system
- # and verify the user's permissions.
-
- if not token or not verify_token(token, scope, permissions):
+ if not token:
+ if optional:
+ return True
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
)
- # this is a placeholder for the actual authentication logic
- return _get_token_payload(token) if token else None
+ # Decoded once and reused. The previous flow decoded the JWT twice per
+ # request: verify_token() decoded to read groups, then the caller
+ # decoded again to build the return value.
+ payload = _get_token_payload(token)
- return _authenicated
+ if not authorize_groups(payload, require_all=permissions, require_any=any_of):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden"
+ )
+ return payload
-def verify_token(
- token: TokenType, scope: Optional[List[str]], permissions: Optional[List[str]]
-) -> bool:
- """
- Placeholder function to verify the token.
- This should contain the logic to check if the token is valid and has the required permissions.
- """
- # Implement your token verification logic here
+ return _authenticated
- payload = _get_token_payload(token)
- # Optionally check scopes and permissions in payload
- if scope:
- if not all(s in payload.get("scope", []) for s in scope):
- return False
- if permissions:
- if not all(p in payload.get("groups", []) for p in permissions):
- return False
- return True
+def _decode(token: str) -> dict:
+ """Verify signature, audience, and issuer, returning the claims."""
+ return jwt.decode(
+ token,
+ get_public_key(token),
+ algorithms=ALGORITHMS,
+ audience=os.environ.get("AUTHENTIK_CLIENT_ID"), # Authentik application
+ issuer=_accepted_issuers() or None,
+ )
-def _get_token_payload(token: str = Depends(oauth2_scheme)):
+def _get_token_payload(token: str) -> dict:
try:
- public_key = get_public_key(token)
- payload = jwt.decode(
- token,
- public_key,
- algorithms=ALGORITHMS,
- audience=os.environ.get(
- "AUTHENTIK_CLIENT_ID"
- ), # Must match Authentik application
- )
- return payload
- except JWTError as e:
+ return _decode(token)
+ except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
@@ -170,17 +279,11 @@ class TokenInvalid(Exception):
def decode_token_payload(token: str) -> dict:
- """Same JWT verification as _get_token_payload (get_public_key/JWKS/
- jwt.decode), but raises TokenInvalid instead of HTTPException(401).
+ """Same JWT verification as _get_token_payload (shared _decode helper),
+ but raises TokenInvalid instead of HTTPException(401).
"""
try:
- public_key = get_public_key(token)
- return jwt.decode(
- token,
- public_key,
- algorithms=ALGORITHMS,
- audience=os.environ.get("AUTHENTIK_CLIENT_ID"),
- )
+ return _decode(token)
except (JWTError, HTTPException) as e:
raise TokenInvalid(str(e)) from e
diff --git a/core/settings.py b/core/settings.py
index 95ea93b68..c29c5719c 100644
--- a/core/settings.py
+++ b/core/settings.py
@@ -30,8 +30,18 @@ def _resolve_version() -> str:
class Settings:
version = _resolve_version()
- def __init__(self):
- self.mode = os.getenv("MODE", "") # Default mode
+ @property
+ def mode(self) -> str:
+ """Deployment mode, read fresh from the environment on every access.
+
+ This used to be snapshotted in __init__. Settings() is instantiated
+ while core.app is imported, which happens before core.factory calls
+ load_dotenv() -- so whether MODE was visible depended on which module
+ happened to call load_dotenv() first. Reading it lazily makes the
+ value independent of import order, which matters because
+ core.permissions gates the authentication bypass on it.
+ """
+ return os.getenv("MODE", "")
def get_enum(self, name: str):
if name == "MODE":
diff --git a/tests/test_authorization.py b/tests/test_authorization.py
new file mode 100644
index 000000000..97608ae9c
--- /dev/null
+++ b/tests/test_authorization.py
@@ -0,0 +1,376 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Authorization wiring tests.
+
+CI runs with AUTHENTIK_DISABLE_AUTHENTICATION=1, so no test here can exercise
+a real Authentik token. These tests cover the parts that are verifiable
+without one:
+
+* which routes have no authentication dependency at all (an inventory test --
+ authorization is opt-in per endpoint, so a forgotten `user:` parameter
+ silently publishes an endpoint and nothing else would catch it),
+* that the anonymous OpenAPI schema advertises exactly those routes,
+* the pure group-membership logic behind the role tiers,
+* the startup guard on the development bypass.
+"""
+
+import pytest
+from fastapi.routing import iter_route_contexts
+
+from core import dependencies, permissions
+from tests import client
+
+# Routes that are allowed to have no authentication dependency. Adding an entry
+# here is a deliberate decision to publish an endpoint anonymously -- it is not
+# a formality to satisfy the test.
+#
+# /health, /_ah/warmup uptime monitors and App Engine warmup
+# /docs-auth, /openapi-auth Swagger UI and schema, no data
+# /disclaimer advertised as terms_of_service by both pygeoapi
+# mounts, so OGC clients fetch it uncredentialed
+# /ngwmn/* polled by the federal NGWMN harvester
+#
+# Not listed, because they never reach this scan:
+# /openapi.json, /docs, /redoc are bare Starlette routes with no dependant.
+# /ogcapi is a Mount -- anonymous by design; /ogcapi-internal is gated by
+# core.internal_ogc_auth.InternalOGCAuthMiddleware, outside Depends().
+EXPECTED_ANONYMOUS_ROUTES = {
+ ("GET", "/health"),
+ ("GET", "/_ah/warmup"),
+ ("GET", "/docs-auth"),
+ ("GET", "/docs-auth/oauth2-redirect"),
+ ("GET", "/openapi-auth.json"),
+ ("GET", "/disclaimer"),
+ ("GET", "/ngwmn/waterlevels/{pointid}"),
+ ("GET", "/ngwmn/wellconstruction/{pointid}"),
+ ("GET", "/ngwmn/lithology/{pointid}"),
+}
+
+# Every dependency callable built by core.permissions.authenticated().
+AUTH_DEPENDENCY_CALLABLES = frozenset(
+ {
+ dependencies.admin_function,
+ dependencies.editor_function,
+ dependencies.viewer_function,
+ dependencies.amp_admin_function,
+ dependencies.amp_editor_function,
+ dependencies.amp_viewer_function,
+ dependencies.lexicon_admin_function,
+ dependencies.lexicon_editor_function,
+ dependencies.no_permission_function,
+ }
+)
+
+
+def _has_auth_dependency(dependant) -> bool:
+ """Walk a route's dependency tree looking for an auth dependency."""
+ if dependant.call in AUTH_DEPENDENCY_CALLABLES:
+ return True
+ return any(_has_auth_dependency(sub) for sub in dependant.dependencies)
+
+
+def _anonymous_routes() -> set:
+ """Every (method, path) with no authentication dependency.
+
+ Walks iter_route_contexts() rather than app.routes: routes registered via
+ include_router() are not flattened into app.routes in this FastAPI version,
+ so a plain scan would see only the endpoints declared directly on `app` and
+ this test would pass while reporting on ~5 of ~130 routes.
+ """
+ found = set()
+ for route_context in iter_route_contexts(client.app.routes):
+ dependant = getattr(route_context, "dependant", None)
+ if dependant is None:
+ # Not an APIRoute (Mounts, bare Starlette routes such as /docs).
+ continue
+ if _has_auth_dependency(dependant):
+ continue
+ path = route_context.path_format or route_context.path
+ for method in route_context.methods or ():
+ if method in ("HEAD", "OPTIONS"):
+ continue
+ found.add((method, path))
+ return found
+
+
+def test_no_unintended_anonymous_routes():
+ """Every route without an auth dependency is one we chose to publish.
+
+ Authorization is declared per endpoint as a `user: _dependency`
+ parameter, not at the router level, so omitting it produces a fully public
+ endpoint with no error anywhere. This test is the only thing that notices.
+ """
+ unexpected = _anonymous_routes() - EXPECTED_ANONYMOUS_ROUTES
+ assert not unexpected, (
+ "These routes have no authentication dependency. Add a `user: "
+ "_dependency` parameter, or add them to "
+ f"EXPECTED_ANONYMOUS_ROUTES if they are meant to be public: "
+ f"{sorted(unexpected)}"
+ )
+
+
+def test_expected_anonymous_routes_still_exist():
+ """Keeps EXPECTED_ANONYMOUS_ROUTES from rotting into a stale allowlist."""
+ stale = EXPECTED_ANONYMOUS_ROUTES - _anonymous_routes()
+ assert not stale, (
+ "EXPECTED_ANONYMOUS_ROUTES lists routes that no longer exist or now "
+ f"require authentication -- remove them: {sorted(stale)}"
+ )
+
+
+def test_public_schema_advertises_only_anonymous_routes():
+ """@in_public_schema must not advertise an authenticated operation.
+
+ Two /thing routes used to carry the decorator (then named @public_route)
+ alongside a viewer_dependency, so the anonymous schema described endpoints
+ that 401 for anonymous callers.
+ """
+ schema = client.get("/openapi.json").json()
+ advertised = {
+ (method.upper(), path)
+ for path, item in schema["paths"].items()
+ for method in item
+ }
+ assert advertised <= EXPECTED_ANONYMOUS_ROUTES, (
+ "The anonymous OpenAPI schema advertises routes that require "
+ "authentication. Remove @in_public_schema from them: "
+ f"{sorted(advertised - EXPECTED_ANONYMOUS_ROUTES)}"
+ )
+
+
+# Group membership logic -------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "groups, expected",
+ [
+ (["Admin"], True),
+ (["Editor"], True),
+ (["Viewer"], True),
+ (["AMPAdmin"], False),
+ ([], False),
+ ],
+)
+def test_admin_satisfies_viewer_tier(groups, expected):
+ """Admin > Editor > Viewer is enforced in code, not by Authentik overlap."""
+ assert (
+ permissions.authorize_groups(
+ {"groups": groups}, require_any=["Admin", "Editor", "Viewer"]
+ )
+ is expected
+ )
+
+
+@pytest.mark.parametrize(
+ "groups, expected",
+ [
+ (["Admin"], True),
+ (["Editor"], False),
+ (["Viewer"], False),
+ ],
+)
+def test_admin_tier_does_not_accept_lower_roles(groups, expected):
+ assert (
+ permissions.authorize_groups({"groups": groups}, require_any=["Admin"])
+ is expected
+ )
+
+
+def test_role_families_stay_orthogonal():
+ """General Admin confers nothing in the AMP or Lexicon families."""
+ payload = {"groups": ["Admin"]}
+ assert not permissions.authorize_groups(
+ payload, require_any=["AMPAdmin", "AMPEditor", "AMPViewer"]
+ )
+ assert not permissions.authorize_groups(
+ payload, require_any=["LexiconAdmin", "LexiconEditor"]
+ )
+
+
+def test_require_all_demands_every_group():
+ assert permissions.authorize_groups(
+ {"groups": ["Admin", "AMPAdmin"]}, require_all=["Admin", "AMPAdmin"]
+ )
+ assert not permissions.authorize_groups(
+ {"groups": ["Admin"]}, require_all=["Admin", "AMPAdmin"]
+ )
+
+
+def test_missing_groups_claim_denies():
+ """A token with no `groups` claim must not satisfy a role requirement."""
+ assert not permissions.authorize_groups({}, require_any=["Viewer"])
+ assert not permissions.authorize_groups({"groups": None}, require_any=["Viewer"])
+
+
+# Bypass configuration guard ---------------------------------------------------
+
+
+@pytest.fixture
+def auth_env(monkeypatch):
+ """Set MODE and AUTHENTIK_DISABLE_AUTHENTICATION for one test."""
+
+ def _set(mode, disabled):
+ monkeypatch.setenv("MODE", mode)
+ monkeypatch.setenv("AUTHENTIK_DISABLE_AUTHENTICATION", disabled)
+
+ return _set
+
+
+@pytest.mark.parametrize("mode", ["production", "staging", "", "Development"])
+def test_bypass_outside_development_refuses_to_boot(auth_env, mode):
+ """The bypass is honored in development only.
+
+ The old guard only rejected MODE=="production", so a deploy with MODE
+ unset served every endpoint anonymously and said nothing about it.
+ """
+ auth_env(mode, "1")
+ with pytest.raises(permissions.AuthConfigurationError) as exc:
+ permissions.assert_auth_configuration()
+ assert "AUTHENTIK_DISABLE_AUTHENTICATION" in str(exc.value)
+
+
+def test_bypass_allowed_in_development(auth_env):
+ auth_env("development", "1")
+ permissions.assert_auth_configuration()
+
+
+@pytest.mark.parametrize("mode", ["production", "staging", "", "development"])
+def test_any_mode_boots_with_auth_enabled(auth_env, mode):
+ auth_env(mode, "0")
+ permissions.assert_auth_configuration()
+
+
+@pytest.mark.parametrize(
+ "raw, expected",
+ [
+ ("1", True),
+ ("0", False),
+ ("", False),
+ ("true", True),
+ ("TRUE", True),
+ ("on", True),
+ ("no", False),
+ ("garbage", False),
+ ],
+)
+def test_authentication_disabled_parsing(monkeypatch, raw, expected):
+ """A non-numeric value must not crash the guard into a bypass."""
+ monkeypatch.setenv("AUTHENTIK_DISABLE_AUTHENTICATION", raw)
+ assert permissions.authentication_disabled() is expected
+
+
+def test_authentication_disabled_defaults_to_enforcing(monkeypatch):
+ monkeypatch.delenv("AUTHENTIK_DISABLE_AUTHENTICATION", raising=False)
+ assert permissions.authentication_disabled() is False
+
+
+def test_mode_is_read_fresh_from_environment(monkeypatch):
+ """settings.mode used to be an import-time snapshot, so whether MODE was
+ visible depended on which module called load_dotenv() first."""
+ from core.settings import settings
+
+ monkeypatch.setenv("MODE", "sentinel-mode")
+ assert settings.mode == "sentinel-mode"
+
+
+# JWKS caching -----------------------------------------------------------------
+
+
+def test_jwks_cache_expires(monkeypatch):
+ """A TTL'd cache, so an Authentik key rotation does not require a redeploy.
+
+ The cache was an unbounded lru_cache: once a rotation invalidated the
+ cached keys every request 401'd with "Invalid signing key" until the
+ process restarted.
+ """
+ permissions.reset_jwks_cache()
+ monkeypatch.setenv("AUTHENTIK_URL", "https://authentik.example/application/o/x/")
+ monkeypatch.setenv("AUTHENTIK_DISABLE_AUTHENTICATION", "0")
+
+ fetches = []
+
+ class _Resp:
+ def raise_for_status(self):
+ pass
+
+ def json(self):
+ return {"keys": [{"kid": f"k{len(fetches)}"}]}
+
+ def _fake_get(url, **kwargs):
+ fetches.append(url)
+ return _Resp()
+
+ monkeypatch.setattr(permissions.httpx, "get", _fake_get)
+
+ clock = {"now": 1000.0}
+ monkeypatch.setattr(permissions.time, "monotonic", lambda: clock["now"])
+
+ permissions.get_jwks()
+ permissions.get_jwks()
+ assert len(fetches) == 1, "within the TTL the cached document is reused"
+ assert fetches[0] == "https://authentik.example/application/o/x/jwks/"
+
+ clock["now"] += permissions.JWKS_TTL_SECONDS + 1
+ permissions.get_jwks()
+ assert len(fetches) == 2, "past the TTL the document is refetched"
+
+ permissions.reset_jwks_cache()
+
+
+def test_jwks_not_fetched_when_bypass_active(monkeypatch):
+ permissions.reset_jwks_cache()
+ monkeypatch.setenv("AUTHENTIK_URL", "https://authentik.example/application/o/x/")
+ monkeypatch.setenv("AUTHENTIK_DISABLE_AUTHENTICATION", "1")
+
+ def _explode(*args, **kwargs):
+ raise AssertionError("JWKS must not be fetched while auth is bypassed")
+
+ monkeypatch.setattr(permissions.httpx, "get", _explode)
+ assert permissions.get_jwks() == {}
+
+
+def test_accepted_issuers_covers_both_slash_spellings(monkeypatch):
+ """The `iss` claim is now verified; operators configure AUTHENTIK_URL with
+ and without a trailing slash, so both spellings must be accepted."""
+ monkeypatch.setenv("AUTHENTIK_URL", "https://authentik.example/application/o/x/")
+ assert set(permissions._accepted_issuers()) == {
+ "https://authentik.example/application/o/x",
+ "https://authentik.example/application/o/x/",
+ }
+
+ monkeypatch.setenv("AUTHENTIK_URL", "https://authentik.example/application/o/x")
+ assert set(permissions._accepted_issuers()) == {
+ "https://authentik.example/application/o/x",
+ "https://authentik.example/application/o/x/",
+ }
+
+
+def test_accepted_issuers_empty_when_unconfigured(monkeypatch):
+ """Empty tuple, which _decode() passes to jose as issuer=None."""
+ monkeypatch.delenv("AUTHENTIK_URL", raising=False)
+ assert permissions._accepted_issuers() == ()
+
+
+def test_dead_scope_parameter_is_gone():
+ """`scope=` was never used and was wrong if it had been: the OIDC scope
+ claim is a space-delimited string, so `s in payload["scope"]` was substring
+ matching."""
+ import inspect
+
+ assert "scope" not in inspect.signature(permissions.authenticated).parameters
+
+
+# ============= EOF =============================================
From fde4c3395141978addb4ac57a54a0e7c93212ff3 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:48:34 +0000
Subject: [PATCH 053/151] build(deps): bump sqlparse from 0.5.5 to 0.6.0 (#831)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.5.5 to
0.6.0.
Changelog
Sourced from sqlparse's
changelog .
Release 0.6.0 (Aug 13, 2026)
Notable Changes
Drop support for Python 3.8 and 3.9. Python 3.10+ is now
required.
IMPORTANT: Fixes a potential denial of service attack (DOS) in the
lexer,
which consumed CPU quadratically on statements containing many unclosed
dollar-quoted literals or multiline comments (CVE-2026-59893). See the
security advisory for details:
https://github.com/andialbrecht/sqlparse/security/advisories/GHSA-prg7-hcfm-mfcr
The vulnerability was discovered by EQSTLab, min8282 and 7thpark.
Thanks for reporting!
IMPORTANT: Fixes a potential denial of service attack (DOS) when
grouping
deeply nested or very wide statements. Building a token group re-read
the
whole group on every step, so a small statement could keep a worker busy
for a long time (CVE-2026-54284, pr848 by alhudz and tonghuaroot).
IMPORTANT: Fixes a potential denial of service attack (DOS) in
format(sql, reindent=True), which consumed CPU
quadratically on long
lists of tuples. See the security advisory for details:
https://github.com/andialbrecht/sqlparse/security/advisories/GHSA-cfqr-cjx5-5jcm
IMPORTANT: Fixes a potential denial of service attack (DOS) on
statements
that consist only of comments (CVE-2026-71491). See the security
advisory
for details:
https://github.com/andialbrecht/sqlparse/security/advisories/GHSA-f2ff-p2ww-7p4p
The vulnerability was discovered by @sanktjodel .
Thanks for reporting!
IMPORTANT: Backslashes are now escaped in the python
and php output
formats. Without escaping, SQL containing a backslash could break out of
the generated string literal (CVE-2026-59894). See the security advisory
for details:
https://github.com/andialbrecht/sqlparse/security/advisories/GHSA-3496-9g83-7v6x
The vulnerability was discovered by @7thParkk . Thanks
for reporting!
Enhancements
Modernize type annotations in top-level API functions using PEP 585
and
PEP 604 syntax.
END FOR and END CASE are now recognized as
keywords.
Bug Fixes
Statement splitting was rewritten on a stack-based architecture.
This fixes
splitting of statements with nested BEGIN ... END blocks
(issue845).
Fix function grouping being skipped in CREATE TABLE ... AS
SELECT
statements when the as keyword is lowercase (pr867 by
Osamaali313).
Recognize ROW_FORMAT as a keyword so that ALTER
TABLE ... ROW_FORMAT=...
no longer merges the table name and the option into a single identifier
(issue773, pr860 by apoorvdarshan).
Recognize MATERIALIZED as a keyword so it is parsed and
formatted
consistently in CREATE MATERIALIZED VIEW statements
(issue752, pr854 by
... (truncated)
Commits
2f40da9
Update version number.
5753f15
Align the changelog entries for this release with previous ones
b9588d9
Unify the benchmark scripts on a shared harness
519e416
Pair comment/dollar-quote delimiters at the lexer position
a51df6d
Measure reindent offsets backwards to avoid quadratic CPU use
73d9ccd
Update CHANGELOG
d1d8060
Fix uncontrolled CPU consumption (ReDoS) in the lexer's handling of
dollar-qu...
ef2012a
Fix quadratic DoS in group_comments (GHSA-f2ff-p2ww-7p4p)
26112dd
Update Changelog.
53ff44b
Escape backslashes in output formatters.
Additional commits viewable in compare
view
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/DataIntegrationGroup/OcotilloAPI/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
pyproject.toml | 2 +-
requirements.txt | 6 +++---
uv.lock | 8 ++++----
3 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 832eb4532..ff715e38a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -89,7 +89,7 @@ dependencies = [
"sqlalchemy-continuum==1.7.0",
"sqlalchemy-searchable==2.1.0",
"sqlalchemy-utils==0.42.1",
- "sqlparse>=0.5.5",
+ "sqlparse>=0.6.0",
"starlette==1.4.1",
"typer==0.27.1",
"typing-extensions==4.16.0",
diff --git a/requirements.txt b/requirements.txt
index d7f6222ef..a5c05c2d7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1995,9 +1995,9 @@ sqlalchemy-utils==0.42.1 \
# via
# ocotilloapi
# sqlalchemy-searchable
-sqlparse==0.5.5 \
- --hash=sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba \
- --hash=sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e
+sqlparse==0.6.0 \
+ --hash=sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9 \
+ --hash=sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f
# via ocotilloapi
starlette==1.4.1 \
--hash=sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19 \
diff --git a/uv.lock b/uv.lock
index a9f1d2cb0..4966d646c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1787,7 +1787,7 @@ requires-dist = [
{ name = "sqlalchemy-continuum", specifier = "==1.7.0" },
{ name = "sqlalchemy-searchable", specifier = "==2.1.0" },
{ name = "sqlalchemy-utils", specifier = "==0.42.1" },
- { name = "sqlparse", specifier = ">=0.5.5" },
+ { name = "sqlparse", specifier = ">=0.6.0" },
{ name = "starlette", specifier = "==1.4.1" },
{ name = "typer", specifier = "==0.27.1" },
{ name = "typing-extensions", specifier = "==4.16.0" },
@@ -3044,11 +3044,11 @@ wheels = [
[[package]]
name = "sqlparse"
-version = "0.5.5"
+version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
]
[[package]]
From ae195261a8982fe5effe45a14a00f0e3bb9fa41a Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 17 Aug 2026 22:06:42 +0000
Subject: [PATCH 054/151] build(deps): bump the uv-non-major group across 1
directory with 15 updates (#832)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the uv-non-major group with 15 updates in the / directory:
| Package | From | To |
| --- | --- | --- |
| [alembic](https://github.com/sqlalchemy/alembic) | `1.19.0` | `1.19.1`
|
| [charset-normalizer](https://github.com/jawah/charset_normalizer) |
`3.4.9` | `3.5.0` |
| [greenlet](https://github.com/python-greenlet/greenlet) | `3.5.4` |
`3.5.5` |
| [numpy](https://github.com/numpy/numpy) | `2.5.1` | `2.5.2` |
| [phonenumbers](https://github.com/daviddrysdale/python-phonenumbers) |
`9.0.36` | `9.0.37` |
| [scramp](https://github.com/tlocke/scramp) | `1.4.16` | `1.4.17` |
| [sentry-sdk[fastapi]](https://github.com/getsentry/sentry-python) |
`2.66.1` | `2.68.0` |
| [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) | `2.0.51` |
`2.0.52` |
| [starlette](https://github.com/Kludex/starlette) | `1.4.1` | `1.6.0` |
| [typing-inspection](https://github.com/pydantic/typing-inspection) |
`0.4.2` | `0.4.4` |
| [uvicorn](https://github.com/Kludex/uvicorn) | `0.52.1` | `0.52.3` |
| [pre-commit](https://github.com/pre-commit/pre-commit) | `4.6.1` |
`4.6.2` |
| [filelock](https://github.com/tox-dev/py-filelock) | `3.32.2` |
`3.32.3` |
| [rasterio](https://github.com/rasterio/rasterio) | `1.5.0` | `1.5.1` |
| [sentry-sdk](https://github.com/getsentry/sentry-python) | `2.66.1` |
`2.68.0` |
Updates `alembic` from 1.19.0 to 1.19.1
Release notes
Sourced from alembic's
releases .
1.19.1
Released: August 8, 2026
bug
Commits
Updates `charset-normalizer` from 3.4.9 to 3.5.0
Release notes
Sourced from charset-normalizer's
releases .
Version 3.5.0
3.5.0
(2026-08-12)
Added
Explicit support for Python 3.15
Fixed
Comparing a CharsetMatch to a non-alias encoding strings (#773 )
Return 0.0 CharsetMatch.multi_byte_usage for empty payloads instead
of crashing (#774 )
A file with both a charset declaration and BOM/SIG did not verify
first the BOM/SIG charset.
iso2022* cases misdetected due to a flaw in our multibyte chunking
logic.
Changed
Replaced the optional mypyc build with Cython extensions while
retaining the
pure Python fallback. The previous engine (mypyc) started to hit rough
limit around
the optimization of our noise/coherence detector while Cython allows us
to
steer the engine toward the right generated optimized sources.
This change SHOULD not impact bundler (e.g. Pyinstaller) as the module
are
immediately discoverable (i.e. not hidden import like mypyc did).
Moreover, a long wished distribution is the abi3 wheels, this will allow
us
to no longer rush each year when a new Python interpreter is released.
We still distribute the interpreter specific wheels for faster
performance.
Applied micro-optimization on several utils.
CharsetMatches no longer sort on each match insertion.
Misc
Removed an old performance optimization attempt in apy.py
(success_fast_tracked+payload_result_cache).
Changelog
Sourced from charset-normalizer's
changelog .
3.5.0
(2026-08-12)
Added
Explicit support for Python 3.15
Fixed
Comparing a CharsetMatch to a non-alias encoding strings (#773 )
Return 0.0 CharsetMatch.multi_byte_usage for empty payloads instead
of crashing (#774 )
A file with both a charset declaration and BOM/SIG did not verify
first the BOM/SIG charset.
iso2022* cases misdetected due to a flaw in our multibyte chunking
logic.
Changed
Replaced the optional mypyc build with Cython extensions while
retaining the
pure Python fallback. The previous engine (mypyc) started to hit rough
limit around
the optimization of our noise/coherence detector while Cython allows us
to
steer the engine toward the right generated optimized sources.
This change SHOULD not impact bundler (e.g. Pyinstaller) as the module
are
immediately discoverable (i.e. not hidden import like mypyc did).
Moreover, a long wished distribution is the abi3 wheels, this will allow
us
to no longer rush each year when a new Python interpreter is released.
We still distribute the interpreter specific wheels for faster
performance.
Applied micro-optimization on several utils.
CharsetMatches no longer sort on each match insertion.
Misc
Removed an old performance optimization attempt in apy.py
(success_fast_tracked+payload_result_cache).
Commits
3325d87
Merge pull request #792
from jawah/update-cibuildwheel-action
77203b1
chore: reformat noxfile.py
8561c22
chore(deps): bump github/codeql-action/upload-sarif (#787 )
25248df
chore(deps): bump actions/setup-python from 6.3.0 to 7.0.0 (#789 )
3eaaf3e
chore: enable cp315t in ci
fbe9fc4
chore: update cibuildwheel for py315 by default
5c7b82a
chore: add emscripten classifier
7d30c21
chore: skip pyodide tests cibw
417d66f
Merge pull request #791
from jawah/patch-1
130afd5
chore: perf script initial warm with big5 dummy content
Additional commits viewable in compare
view
Updates `greenlet` from 3.5.4 to 3.5.5
Changelog
Sourced from greenlet's
changelog .
3.5.5 (2026-08-10)
Link the C++ runtime statically into the Windows wheels again, as
the
Appveyor builds did through 3.3.0. Since 3.3.1
_greenlet.pyd
imported MSVCP140.dll, which no Windows CPython
distribution ships,
so importing greenlet failed on machines without the Visual C++
redistributable. See issue 525
<https://github.com/python-greenlet/greenlet/issues/525>_.
Issue
and pull request by Daniel Sticker.
.. note::
Binary 3.15 wheels are now built with 3.15.0rc1. This should be
compatible with future 3.15 releases and is believed compatible
with 3.15b4 as well (but not earlier versions).
Commits
ddb1453
Preparing release 3.5.5
7de515c
Update CHANGES: Credit for issue 525 and note about 3.15 binary wheels
[skip ci]
f3746aa
Merge pull request #526
from stickerdaniel/windows-static-runtime
f1ce3ca
Restore static linking of the C++ runtime for Windows wheels
b6690a5
Merge pull request #523
from python-greenlet/dependabot/github_actions/github...
726cc38
Bump the github-actions group with 3 updates
e5c5f4c
Merge pull request #522
from ddorian/c-stack-refs-test-detection
be55a59
Check that the suspended greenlet is what keeps the class alive
e9c01dd
Back to development: 3.5.5
See full diff in compare
view
Updates `numpy` from 2.5.1 to 2.5.2
Release notes
Sourced from numpy's
releases .
v2.5.2 (Aug 9, 2026)
NumPy 2.5.2 Release Notes
The NumPy 2.5.2 is a patch release that fixes bugs discovered after
the 2.5.1
release. The big news is that it includes wheels for the newly released
Python 3.15.0rc1.
This release supports Python versions 3.12-3.15
C API changes
PyArray_StringDTypeObject is opaque under the abi3t
stable ABI
The PyArray_StringDTypeObject was accidentally exposed
in NumPy
2.5 when targeting the free-threading-compatible stable ABI
(Py_TARGET_ABI3T). PyArray_StringDTypeObject
is now an opaque
struct: extensions compiled that way cannot access its fields, since
the struct layout depends on the size of the object header. Any code
that accessed PyArray_StringDTypeObject fields in an abi3t
build
would have crashed, so we are making this API change in a bugfix
release.
The NpyString allocator API remains usable by passing
the
descriptor object pointer, e.g.
NpyString_acquire_allocator((PyArray_StringDTypeObject
*)descr).
(gh-31771 )
Contributors
A total of 16 people contributed to this release. People with a
"+" by their
names contributed a patch for the first time.
Abhijeetsingh Meena +
Charalampos Stratakis
Charles Harris
Chris Ninham +
David Woods
Geonho +
Gopu Yeshwanth Reddy +
Iason Krommydas
Ijtihed Kilani
Jelle Zijlstra +
Joren Hammudoglu
Kumar Aditya
Mike Boyle
Nathan Goldbaum
Raghuveer Devulapalli
Sebastian Berg
... (truncated)
Commits
48fecee
REL: Prepare for the NumPy 2.5.2 release (#32226 )
ecf599c
Merge pull request #32221
from charris/backport-32151
3c7ac97
Merge pull request #32220
from charris/backport-32205
23b30f4
BUG: avoid segfaults when legacy copyswap slot is not defined (#32151 )
4964ca8
TYP: isclose shape-typing fix for 2d array-likes (#32205 )
c37ed94
MAINT: Skip limited_api tests on some platforms. (#32214 )
5cfd73b
Merge pull request #32206
from charris/update-cibuildwheel
d8262bc
MAINT: Update cibuildwheel to v4.2.0
988d94d
Merge pull request #32158
from charris/backport-32133
b2e4f97
BUG: avoid possible stack overflow in arraydescr_dealloc (#32133 )
Additional commits viewable in compare
view
Updates `phonenumbers` from 9.0.36 to 9.0.37
Commits
Updates `scramp` from 1.4.16 to 1.4.17
Commits
Updates `sentry-sdk[fastapi]` from 2.66.1 to 2.68.0
Release notes
Sourced from sentry-sdk[fastapi]'s
releases .
2.68.0
Important
We're making enable_logs and enable_metrics
no-op with this release (#7177 ),
and they'll be dropped in the next major.
Previously, enable_logs also controlled automatic logs
collection from the logging and Loguru integrations. These integrations
now get an integration-level capture_sentry_logs boolean
option to allow for more control over the auto-collection. These options
are False by default, i.e., nothing is
auto-collected without your explicit opt-in .
Action Needed
If you had enable_logs set to True:
If you were using the sentry_sdk.logger.X API, no
action necessary, the API will just work.
If you were auto-collecting logs from either
LoggingIntegration or LoguruIntegration, the
auto-collection will be turned off in this release . You
can switch auto-collection on explicitly with:
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.loguru import LoguruIntegration
sentry_sdk.init(
integrations=[
LoggingIntegration(capture_sentry_logs=True),
LoguruIntegration(capture_sentry_logs=True),
],
)
If you had enable_logs set to False:
If you were using it to gate usages of the
sentry_sdk.logger.X API, you'll need to remove the calls
entirely or define a before_send_log callback to filter out
unwanted logs.
If you has enable_metrics set to False:
Any metrics emitted using the metrics API will be emitted. You'll
need to drop them in a before_send_metric or remove the
calls to the API.
Why We're Doing This
We recognize this is a disruptive change for some folks and want to
make it clear this is a one-off. We're removing the options because they
were an unnecessary hurdle that one had to jump through to be able to
use logs and metrics, and it was confusing why the logging API would not
just work on its own. On the other hand, we wanted to give you more
fine-grained control over automatic collection.
New Features ✨
Other
Bug Fixes 🐛
Internal Changes 🔧
... (truncated)
Changelog
Sourced from sentry-sdk[fastapi]'s
changelog .
2.68.0
Important
We're making enable_logs and enable_metrics
no-op with this release (#7177 ),
and they'll be dropped in the next major.
Previously, enable_logs also controlled automatic logs
collection from the logging and Loguru integrations. These integrations
now get an integration-level capture_sentry_logs boolean
option to allow for more control over the auto-collection. These options
are False by default, i.e., nothing is
auto-collected without your explicit opt-in .
Action Needed
If you had enable_logs set to True:
If you were using the sentry_sdk.logger.X API, no
action necessary, the API will just work.
If you were auto-collecting logs from either
LoggingIntegration or LoguruIntegration, the
auto-collection will be turned off in this release . You
can switch auto-collection on explicitly with:
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.loguru import LoguruIntegration
sentry_sdk.init(
integrations=[
LoggingIntegration(capture_sentry_logs=True),
LoguruIntegration(capture_sentry_logs=True),
],
)
If you had enable_logs set to False:
If you were using it to gate usages of the
sentry_sdk.logger.X API, you'll need to remove the calls
entirely or define a before_send_log callback to filter out
unwanted logs.
If you has enable_metrics set to False:
Any metrics emitted using the metrics API will be emitted. You'll
need to drop them in a before_send_metric or remove the
calls to the API.
Why We're Doing This
We recognize this is a disruptive change for some folks and want to
make it clear this is a one-off. We're removing the options because they
were an unnecessary hurdle that one had to jump through to be able to
use logs and metrics, and it was confusing why the logging API would not
just work on its own. On the other hand, we wanted to give you more
fine-grained control over automatic collection.
New Features ✨
Other
Bug Fixes 🐛
... (truncated)
Commits
c819e66
Update README
90446df
Update CHANGELOG.md
cb2499c
release: 2.68.0
1c3b50d
ref: Flush trace buckets when segment spans finish (#7170 )
c200bdf
test(django): Remove dead code and stale markers from the Django test
suite (...
4e4ea83
ref(boto3): Move crumbs to integration (#7165 )
0f0cd1f
ref(stdlib): Move crumbs to integration (#7161 )
2fef9bc
chore: Make enable_logs, enable_metrics no-op
(#7177 )
8177739
ref(httpx,httpx2): Move crumbs to integrations (#7149 )
e4d7398
ref(pyreqwest): Move crumbs to integration (#7148 )
Additional commits viewable in compare
view
Updates `sqlalchemy` from 2.0.51 to 2.0.52
Release notes
Sourced from sqlalchemy's
releases .
2.0.52
Released: August 11, 2026
platform
orm
[orm] [bug] Fixed a result-column misalignment bug
in ORM-enabled UPDATE statements
where synchronize_session="fetch" is in use,
either explicitly or
because the statement uses constructs such as CTEs that implicitly
select
for it. Columns in rows returned by .returning() could be
returned
under incorrect keys (e.g. row[SomeClass.a] returning the
value of
a different column), a problem most likely to manifest under concurrent
workloads. ORM DELETE statements were not affected.
References: #13439
[orm] [bug] Fixed bug where a failed
_orm.Session.bulk_insert_mappings(),
_orm.Session.bulk_update_mappings() or
_orm.Session.bulk_save_objects() call could leave the
_orm.Session permanently in a "flushing" state,
such as when the
transaction could not be begun because a previous flush had left it
needing a rollback. Unlike _orm.Session.flush(), the bulk
methods
set the internal flushing flag and began the transaction outside of the
try/finally block that resets it, so that
neither
_orm.Session.rollback() nor
_orm.Session.close() would clear
it, and every subsequent flush would raise InvalidRequestError:
Session is already flushing. Pull request courtesy Hamody We.
References: #13485
[orm] [bug] Fixed issue where unpickling an ORM
object that were loaded using loader
options making use of wildcard tokens, such as
_orm.load_only() or
_orm.raiseload() with "*", would
fail with KeyError or
IndexError if the process doing the unpickling had not yet
constructed
a loader path making use of that same token. This would typically be
observed when the object were unpickled in a separate process, such as
with the spawn or forkserver multiprocessing
start methods, the
latter of which became the default on POSIX platforms as of Python 3.14.
The internal collection of these tokens is now established up front, so
that it is identical in every process.
... (truncated)
Commits
Updates `starlette` from 1.4.1 to 1.6.0
Release notes
Sourced from starlette's
releases .
Version 1.6.0
What's Changed
New Contributors
Full Changelog : https://github.com/Kludex/starlette/compare/1.5.1...1.6.0
Version 1.5.1
What's Changed
Full Changelog : https://github.com/encode/starlette/compare/1.5.0...1.5.1
Version 1.5.0
This release is all about giving GZipMiddleware some
love. 🗜️
What's Changed
Full Changelog : https://github.com/encode/starlette/compare/1.4.1...1.5.0
Changelog
Sourced from starlette's
changelog .
1.6.0 (August 8, 2026)
Added
Add max_body_size to Starlette and route
classes #3431 .
Expose http.response.debug information via response
extensions #3130 .
1.5.1 (August 8, 2026)
Fixed
Reject inverted single-byte ranges in FileResponse #3389 .
Limit FileResponse to 100 ranges #3430 .
1.5.0 (August 8, 2026)
Added
Add exclude_content_types parameter to
GZipMiddleware #3418 .
Changed
Expand default excluded content types in GZipMiddleware
#3421 .
Fixed
Flush GZip output for each streamed chunk #3419 .
Skip compression of partial responses in GZipMiddleware
#3420 .
Commits
4f250d6
Version 1.6.0 (#3434 )
9eea41a
Expose http.response.debug info via response extensions (#3130 )
38f8999
Add max_body_size to Starlette and route
classes (#3431 )
c41236c
Version 1.5.1 (#3432 )
9c500db
Limit FileResponse to 100 ranges (#3430 )
78ae82c
Reject inverted single-byte Range like bytes=5-4 (#3389 )
c1d6eda
chore(deps): bump pymdown-extensions from 11.0 to 11.0.1 (#3429 )
ee66ca4
chore(deps): bump the python-packages group across 1 directory with 8
updates...
00d1016
fix(tests): skip test_staticfiles_filename_too_long on Windows where
os.pathc...
d96887e
Add Pydantic Logfire banner to the docs (#3428 )
Additional commits viewable in compare
view
Updates `typing-inspection` from 0.4.2 to 0.4.4
Release notes
Sourced from typing-inspection's
releases .
v0.4.3 2026-08-10
What's Changed
Drop support for Python 3.9 by @Viicos in #52
Avoid module getattr() calls in
typing_objects functions by @Viicos in #57
Add Python 3.15 support by @Viicos in #59
Full Changelog : https://github.com/pydantic/typing-inspection/compare/v0.4.2...v0.4.3
Changelog
Sourced from typing-inspection's
changelog .
v0.4.4 (2026-08-12)
Add typing_objects.DEPRECATED_ALIASES_ID by @Viicos in #63
v0.4.3 (2026-08-10)
Drop support for Python 3.9 by @Viicos in #52
Avoid module getattr() calls in
typing_objects functions by @Viicos in #57
Add Python 3.15 support by @Viicos in #59
Commits
Updates `uvicorn` from 0.52.1 to 0.52.3
Release notes
Sourced from uvicorn's
releases .
Version 0.52.3
Changed
Update zttp to 0.0.24 and use its combined receive
path, improving HTTP/1.1 request parsing performance (#3067 )
Full Changelog : https://github.com/Kludex/uvicorn/compare/0.52.2...0.52.3
Version 0.52.2
Fixed
Update zttp to 0.0.22, fixing bodyless request receives
and improving HTTP/1 request parsing performance (#3063 )
Full Changelog : https://github.com/Kludex/uvicorn/compare/0.52.1...0.52.2
Changelog
Sourced from uvicorn's
changelog .
0.52.3 (August 13, 2026)
Changed
Update zttp to 0.0.24 and use its combined receive
path, improving HTTP/1.1 request parsing performance (#3067 )
0.52.2 (August 13, 2026)
Fixed
Update zttp to 0.0.22, fixing bodyless request receives
and improving HTTP/1 request parsing performance (#3063 )
Commits
Updates `pre-commit` from 4.6.1 to 4.6.2
Release notes
Sourced from pre-commit's
releases .
pre-commit v4.6.2
Fixes
Fix language: node hooks that contain
"scripts": {"build": ...} with
npm 11.x.
Changelog
Sourced from pre-commit's
changelog .
4.6.2 - 2026-08-10
Fixes
Fix language: node hooks that contain
"scripts": {"build": ...} with
npm 11.x.
Commits
Updates `filelock` from 3.32.2 to 3.32.3
Release notes
Sourced from filelock's
releases .
3.32.3
What's Changed
Full Changelog : https://github.com/tox-dev/filelock/compare/3.32.2...3.32.3
Changelog
Sourced from filelock's
changelog .
###########
Changelog
###########
.. towncrier-draft-entries:: Unreleased
.. towncrier release notes start
3.32.3 (2026-08-13)
The fork-safety audit hook no longer prints Exception ignored
in audit hook with a TypeError when an audit
event fires during interpreter shutdown, after CPython has already
cleared the module globals. :pr:701
3.32.2 (2026-07-29)
A SoftReadWriteLock or SoftFileLease
acquire whose heartbeat thread fails to start now unlinks its marker and
hands the claim back, instead of leaving an unrefreshed marker a peer
takes while the caller believes it still holds
the lock. :pr:691
3.32.1 (2026-07-26)
Canceling an AsyncSoftReadWriteLock acquire now
releases the claim instead of leaking a marker whose heartbeat
wedges every contender. :pr:686
3.32.0 (2026-07-21)
SoftReadWriteLock closes the directory handle it opens
to scan for readers as soon as a scan stops early, rather
than holding it until the generator is collected.
:pr:685
Declare support for Python 3.15 and run the test suite against it
and its free-threaded build, both currently in beta.
:pr:683
The source distribution ships the capability probes the tests
import, and reading one no longer needs coverage
installed, so the suite runs from an unpacked sdist instead of failing
on a missing coverage_pragmas. :pr:685
3.31.2 (2026-07-21)
filelock imports again on runtimes whose
errno omits ENOTSUP, such as GraalPy, where
importing the package
raised ImportError. It probes the code instead, preferring
ENOTSUP, falling back to EOPNOTSUPP where that
name is absent, and dropping to ENOSYS/EXDEV
where neither exists. Platforms defining ENOTSUP keep their
behavior. :pr:681
... (truncated)
Commits
4aa742c
Release 3.32.3
fb5ab3e
🐛 fix(fork): survive audit events during interpreter shutdown (#703 )
35f759c
📄 docs: publish llms.txt from the docs build (#700 )
4b6e966
[pre-commit.ci] pre-commit autoupdate (#699 )
0e0f666
build(deps): bump pypa/gh-action-pypi-publish from 1.14.1 to 1.14.2 (#698 )
6689d82
🧪 test(strict): deflake close-fault injections on graalpy (#697 )
df67bf7
[pre-commit.ci] pre-commit autoupdate (#696 )
See full diff in compare
view
Updates `rasterio` from 1.5.0 to 1.5.1
Release notes
Sourced from rasterio's
releases .
1.5.1
Version 1.5.1 fixes a number of bugs and unblocks the release of
affine 3.0.0.
Thanks for being patient, folks! It's a weird time in the open source
software world.
Bug fixes
DatasetBase.__dealloc__ has been removed and replaced
by a __del__ method modeled after that of
io.IOBase. This eliminates cryptic partial error messages
from being printed as rio-calc finishes and finalizes (gh-3612 ).
The "bbox" attribute of GeoJSON data is no longer used by
features.bounds() when optional parameters (transform and/or
north_up=False) are provided. An unreported bug where transformation
matrices were not applied to feature and geometry collections has also
been fixed (gh-3609 ).
The size guard in sieve() was defective for 3D sources and has been
fixed (gh-3606 ).
As a side benefit, the dimensionality of output has been made consistent
with the read() method of datasets: 3D output is no longer automatically
squeezed to 2D. The previous behavior was a bug.
A test of subdataset support is skipped for GDAL versions >=
3.13.0 (gh-3534 ).
The most recent versions of GDAL have a netCDF filename parsing
defect.
Acknowledge new runtime warnings about invalid type casting using
numpy (gh-3599 ).
Acknowledge the Python deprecation warnings from using
"fork" start mode in testing warp error propagation (gh-3600 ).
Eliminate numpy deprecation warnings by reshaping views instead of
setting their shape attributes (gh-3592 ).
Require pyparsing version >= 3.0 to eliminate a deprecation
warning for use of parseString (gh-3551 ).
Remove a dependency on unmainted cligj by bringing some CLI options
back into Rasterio (gh-3550 ).
Avoid a ValueError in reproject() when using a "=="
comparison between array masks and np.ma.nomasked (gh-3531 ).
Documentation
Note that PROJ transformation grids and the PROJ_NETWORK environment
variable are required to apply vertical (datum) shifts when reprojecting
or transforming (gh-2929 ).
Dependencies
Rasterio 1.5.1 is compatible with affine versions 3.0rc1 and
newer.
Packaging:
Wheels include GDAL 3.12.4, netCDF 4.10.1, cURL 8.20.0, and PROJ
9.8.1. Python 3.15 is supported for the first time.
Changelog
Sourced from rasterio's
changelog .
1.5.1 (2026-08-07)
Bug fixes:
DatasetBase.dealloc has been removed and replaced
by a del method
modeled after that of io.IOBase. This eliminates cryptic partial error
messages from being printed as rio-calc finishes and finalizes (gh-3612 ).
The "bbox" attribute of GeoJSON data is no longer used by
features.bounds()
when optional parameters (transform and/or north_up=False) are provided.
An
unreported bug where transformation matrices were not applied to feature
and
geometry collections has also been fixed (gh-3609 ).
The size guard in sieve() was defective for 3D sources and has been
fixed
(gh-3606 ).
As a side benefit, the dimensionality of output has been made
consistent with the read() method of datasets: 3D output is no longer
automatically squeezed to 2D. The previous behavior was a bug.
A test of subdataset support is skipped for GDAL versions >=
3.13.0
(gh-3534 ).
The most recent versions of GDAL have a NetCDF filename parsing
defect.
Acknowledge new runtime warnings about invalid type casting using
numpy
(gh-3599 ).
Acknowledge the Python deprecation warnings from using
"fork" start mode in
testing warp error propagation (gh-3600 ).
Eliminate numpy deprecation warnings by reshaping views instead of
setting
their shape attributes (gh-3592 ).
Require pyparsing version >= 3.0 to eliminate a deprecation
warning for use
of parseString (gh-3551 ).
Remove a dependency on unmaintained cligj by bringing some CLI
options back
into Rasterio (gh-3550 ).
Avoid a ValueError in reproject() when using a "=="
comparison between array
masks and np.ma.nomasked (gh-3531 ).
Documentation:
Note that PROJ transformation grids and the PROJ_NETWORK environment
variable
are required to apply vertical (datum) shifts when reprojecting or
transforming (gh-2929 ).
Dependencies:
Rasterio 1.5.1 is compatible with affine versions 3.0rc1 and
newer.
Packaging:
Wheels include GDAL 3.12.4, netCDF 4.10.1, cURL 8.20.0, and PROJ
9.8.1.
Commits
Updates `sentry-sdk` from 2.66.1 to 2.68.0
Release notes
Sourced from sentry-sdk's
releases .
2.68.0
Important
We're making enable_logs and enable_metrics
no-op with this release (#7177 ),
and they'll be dropped in the next major.
Previously, enable_logs also controlled automatic logs
collection from the logging and Loguru integrations. These integrations
now get an integration-level capture_sentry_logs boolean
option to allow for more control over the auto-collection. These options
are False by default, i.e., nothing is
auto-collected without your explicit opt-in .
Action Needed
If you had enable_logs set to True:
If you were using the sentry_sdk.logger.X API, no
action necessary, the API will just work.
If you were auto-collecting logs from either
LoggingIntegration or LoguruIntegration, the
auto-collection will be turned off in this release . You
can switch auto-collection on explicitly with:
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.loguru import LoguruIntegration
sentry_sdk.init(
integrations=[
LoggingIntegration(capture_sentry_logs=True),
LoguruIntegration(capture_sentry_logs=True),
],
)
If you had enable_logs set to False:
If you were using it to gate usages of the
sentry_sdk.logger.X API, you'll need to remove the calls
entirely or define a before_send_log callback to filter out
unwanted logs.
If you has enable_metrics set to False:
Any metrics emitted using the metrics API will be emitted. You'll
need to drop them in a before_send_metric or remove the
calls to the A...
_Description has been truncated_
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
pyproject.toml | 24 +-
requirements.txt | 726 ++++++++++++++++++++++++++++-------------------
uv.lock | 471 +++++++++++++++++-------------
3 files changed, 722 insertions(+), 499 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index ff715e38a..07a73d554 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,7 +10,7 @@ dependencies = [
"aiohttp==3.14.3",
"aiosignal==1.4.0",
"aiosqlite==0.22.1",
- "alembic==1.19.0",
+ "alembic==1.19.1",
"annotated-types==0.8.0",
"anyio==4.14.2",
"apitally[fastapi]==0.25.1",
@@ -23,7 +23,7 @@ dependencies = [
"cachetools==7.1.7",
"certifi==2026.7.22",
"cffi==2.1.1",
- "charset-normalizer==3.4.9",
+ "charset-normalizer==3.5.0",
"click==8.4.2",
"cloud-sql-python-connector==1.21.0",
"cryptography==50.0.0",
@@ -41,7 +41,7 @@ dependencies = [
"google-crc32c==1.8.0",
"google-resumable-media==2.10.1",
"googleapis-common-protos==1.75.1",
- "greenlet==3.5.4",
+ "greenlet==3.5.5",
"gunicorn==23.0.0",
"h11==0.16.0",
"httpcore==1.0.9",
@@ -52,12 +52,12 @@ dependencies = [
"mako==1.4.1",
"markupsafe==3.0.3",
"multidict==6.7.1",
- "numpy==2.5.1",
+ "numpy==2.5.2",
"packaging==26.3",
"pandas==2.3.2",
"pandas-stubs~=2.3.2",
"pg8000==1.31.5",
- "phonenumbers==9.0.36",
+ "phonenumbers==9.0.37",
"pillow==12.3.0",
"pluggy==1.6.0",
"propcache==0.5.2",
@@ -80,24 +80,24 @@ dependencies = [
"pytz==2026.3.post1",
"requests==2.34.2",
"rsa==4.9.1",
- "scramp==1.4.16",
- "sentry-sdk[fastapi]==2.66.1",
+ "scramp==1.4.17",
+ "sentry-sdk[fastapi]==2.68.0",
"shapely==2.1.2",
"six==1.17.0",
"sniffio==1.3.1",
- "sqlalchemy==2.0.51",
+ "sqlalchemy==2.0.52",
"sqlalchemy-continuum==1.7.0",
"sqlalchemy-searchable==2.1.0",
"sqlalchemy-utils==0.42.1",
"sqlparse>=0.6.0",
- "starlette==1.4.1",
+ "starlette==1.6.0",
"typer==0.27.1",
"typing-extensions==4.16.0",
- "typing-inspection==0.4.2",
+ "typing-inspection==0.4.4",
"tzdata==2026.3",
"urllib3==2.7.0",
"utm==0.9.0",
- "uvicorn==0.52.1",
+ "uvicorn==0.52.3",
"yarl==1.24.5",
"pymssql>=2.3.13",
]
@@ -134,7 +134,7 @@ dev = [
"black>=26.5.1",
"faker>=25.0.0",
"flake8>=7.3.0",
- "pre-commit>=4.6.1",
+ "pre-commit>=4.6.2",
"pyhamcrest>=2.0.3",
"pytest>=9.1.1",
"pytest-cov>=6.2.1",
diff --git a/requirements.txt b/requirements.txt
index a5c05c2d7..f628a6a45 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -149,9 +149,9 @@ aiosqlite==0.22.1 \
--hash=sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650 \
--hash=sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb
# via ocotilloapi
-alembic==1.19.0 \
- --hash=sha256:6487c612fc719dcfa22b17d2dd5b2b458929641e6aa2f0b65b135727f5e6d501 \
- --hash=sha256:cf839d3849116aab3cc047e09c6968b9bd6b2fde61b6bb7c1e97352fe5503580
+alembic==1.19.1 \
+ --hash=sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be \
+ --hash=sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648
# via ocotilloapi
annotated-doc==0.0.5 \
--hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
@@ -404,100 +404,179 @@ cffi==2.1.1 \
# via
# cryptography
# ocotilloapi
-charset-normalizer==3.4.9 \
- --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \
- --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \
- --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \
- --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \
- --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \
- --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \
- --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \
- --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \
- --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \
- --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \
- --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \
- --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \
- --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \
- --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \
- --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \
- --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \
- --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \
- --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \
- --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \
- --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \
- --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \
- --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \
- --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \
- --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \
- --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \
- --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \
- --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \
- --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \
- --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \
- --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \
- --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \
- --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \
- --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \
- --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \
- --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \
- --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \
- --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \
- --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \
- --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \
- --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \
- --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \
- --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \
- --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \
- --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \
- --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \
- --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \
- --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \
- --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \
- --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \
- --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \
- --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \
- --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \
- --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \
- --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \
- --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \
- --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \
- --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \
- --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \
- --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \
- --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \
- --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \
- --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \
- --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \
- --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \
- --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \
- --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \
- --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \
- --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \
- --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \
- --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \
- --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \
- --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \
- --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \
- --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \
- --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \
- --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \
- --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \
- --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \
- --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \
- --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \
- --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \
- --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \
- --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \
- --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \
- --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \
- --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \
- --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \
- --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \
- --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \
- --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \
- --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \
- --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \
- --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115
+charset-normalizer==3.5.0 \
+ --hash=sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08 \
+ --hash=sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6 \
+ --hash=sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6 \
+ --hash=sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f \
+ --hash=sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9 \
+ --hash=sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b \
+ --hash=sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74 \
+ --hash=sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3 \
+ --hash=sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b \
+ --hash=sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f \
+ --hash=sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046 \
+ --hash=sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a \
+ --hash=sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4 \
+ --hash=sha256:143792a43e06dc3b27fc891948406e251502dc19ff9216cd80182b79131be5c5 \
+ --hash=sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af \
+ --hash=sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19 \
+ --hash=sha256:17a0fd0e23961c2c017372e37aabc7ca8fceb9e10ad898977dfb40ad3927baae \
+ --hash=sha256:17db18db9a1374d5b9d9a3252f980b4243b0b4efd1df03fac78bb587f6ce98cd \
+ --hash=sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e \
+ --hash=sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458 \
+ --hash=sha256:1a573e1e428f93908e79e04b349717f400e720f2f82285f0aaaf3ee0ff7f4c79 \
+ --hash=sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075 \
+ --hash=sha256:1cdfed4d7a59333c8220c67dd3be4e7a6c887b67453a64394022dcc919570add \
+ --hash=sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a \
+ --hash=sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0 \
+ --hash=sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6 \
+ --hash=sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e \
+ --hash=sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26 \
+ --hash=sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1 \
+ --hash=sha256:2403b489c103e9a18c835863fc6dd54361355c8291d4cafdb37492b683440b9b \
+ --hash=sha256:2df26d4134948616be0ece05d0b24d621d3990f37147b5883c52052b613ef1f5 \
+ --hash=sha256:301bfc4877c4f4f62b344235ecc58d06c901683801636eef819f88769c315ba2 \
+ --hash=sha256:30ae26a1adcd943690dcbbc47f28be762bae9e08ad7442b78c86b1c0dd5a626c \
+ --hash=sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3 \
+ --hash=sha256:32e6d56dd825205f81e5c45bcebb4df6a11fb2bbf4969a01ef156d6ced90c224 \
+ --hash=sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700 \
+ --hash=sha256:3587d94b5c9f05c2dc4c3f3d47aba6375ff141a21adae3051d8d4d53e8a937c0 \
+ --hash=sha256:3684ebbdffd51329ac44245d1d227d90b965797aa1a8abd026568a1f6ae88811 \
+ --hash=sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991 \
+ --hash=sha256:38a395079f229a631dece74e24c69c1f612536dd51f345a7d6a98abe2d3e047a \
+ --hash=sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be \
+ --hash=sha256:3bfbe543d957213fc9a3db4979a8e171b7aa7504c1d737029defdb03a6095a38 \
+ --hash=sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee \
+ --hash=sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44 \
+ --hash=sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb \
+ --hash=sha256:420b19411959eec115063229536788e6b32d0a7fa907d6b940317919120d702d \
+ --hash=sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394 \
+ --hash=sha256:4346a693c08b1d0cfc0e3325bfb0ecd4322fb1a6904d68cf416f8da5e981b234 \
+ --hash=sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0 \
+ --hash=sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0 \
+ --hash=sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e \
+ --hash=sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9 \
+ --hash=sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3 \
+ --hash=sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03 \
+ --hash=sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2 \
+ --hash=sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca \
+ --hash=sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715 \
+ --hash=sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f \
+ --hash=sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5 \
+ --hash=sha256:5a54587f93f2e289f8faf25b35c997d4cc75cf677485ac6f50c985715989f99c \
+ --hash=sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293 \
+ --hash=sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144 \
+ --hash=sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74 \
+ --hash=sha256:5f51a19dc52197a20218b05ec5336d0c6b3b09935f838724722032c8d45dc91a \
+ --hash=sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e \
+ --hash=sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d \
+ --hash=sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956 \
+ --hash=sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d \
+ --hash=sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f \
+ --hash=sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6 \
+ --hash=sha256:69d647cf158eb6bc9c99503292abed1f2079a2de5859f06a403f8aee6417475d \
+ --hash=sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438 \
+ --hash=sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e \
+ --hash=sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1 \
+ --hash=sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9 \
+ --hash=sha256:6da562a20a49673fe365b05750e98d03bb2c5f8b8d03562b014c1abb3df739f1 \
+ --hash=sha256:6e44bc2780516b3df986d6fe33103c7080cd9dcd5576fe3cb4b0f64309c8f22b \
+ --hash=sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b \
+ --hash=sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a \
+ --hash=sha256:74892fe9f33d204860e782e0a2030bb39f9f0af1e7a24f7d5a5b632df311f655 \
+ --hash=sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4 \
+ --hash=sha256:7cdded069549b5eae3d5d9bb6c2e5bb4fe83f9b81863e2a193cd747bf197aebb \
+ --hash=sha256:7faa47b56070b3dd6f4898ed28528843ab130d53266cb9948d9b1f3bb1a5c5e8 \
+ --hash=sha256:7ffc43fe52618fcd7abc6ee0b46aea527db10da73305fcc6aaf9710ac7a33ec7 \
+ --hash=sha256:815f143a91983ba3041bba066e492ae3c42de523fb1c699685a1abf3313b7d1b \
+ --hash=sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64 \
+ --hash=sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c \
+ --hash=sha256:830c04a49998b5ed58c8b642c65b7b26419397f52392a64121ba9fd0e95e7f9f \
+ --hash=sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731 \
+ --hash=sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3 \
+ --hash=sha256:85f9e0e2724bbddf05de65e5fb03b73eb23e985b7df4259c1d19feb302eb8dc2 \
+ --hash=sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516 \
+ --hash=sha256:8b8788f114845c01f2b520e0b91ea58d143276cfc0483aa943e815f7b9555c15 \
+ --hash=sha256:8cb9b6892b53bd6d11fa4cde3dbee020b1f0b6656be1fbaa1ec0d4324a7839db \
+ --hash=sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc \
+ --hash=sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d \
+ --hash=sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0 \
+ --hash=sha256:9491f594859b68052edebd69e05fb045055a713b57a67974e6c1553b4e503c39 \
+ --hash=sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78 \
+ --hash=sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603 \
+ --hash=sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d \
+ --hash=sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea \
+ --hash=sha256:9a1d9b13e5e394e13e3c316f0d910d100b17681ff59797f30da1dba032061296 \
+ --hash=sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85 \
+ --hash=sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76 \
+ --hash=sha256:9e0213f3f8a2674a6778be299aea1d6dc6dda015aab86f683bca6d78f81f27bb \
+ --hash=sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7 \
+ --hash=sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97 \
+ --hash=sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897 \
+ --hash=sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec \
+ --hash=sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d \
+ --hash=sha256:a565303d118ea3b94a4b6c076bf568069726be414e43b06d58f7070b076ce11d \
+ --hash=sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4 \
+ --hash=sha256:a7cb4cd266bd85613367fb85a30cfbf6fe6349919e87e18ca8dba584951bfb8a \
+ --hash=sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3 \
+ --hash=sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8 \
+ --hash=sha256:ac68ebfa549cc623e0e9add2937526340c629ccf667b4da85b7ef5f99e70bbd9 \
+ --hash=sha256:aff38231e3171c578b2c449a01afa44e9ff40844597a32873da102394f63d28e \
+ --hash=sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288 \
+ --hash=sha256:b787efadba00f5da6fe89513bfbe3852d52ca3a448fdec165765cb3b44a80248 \
+ --hash=sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3 \
+ --hash=sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6 \
+ --hash=sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73 \
+ --hash=sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b \
+ --hash=sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a \
+ --hash=sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce \
+ --hash=sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f \
+ --hash=sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b \
+ --hash=sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706 \
+ --hash=sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9 \
+ --hash=sha256:c5e981a5ac8641381efe6f0029467500661616a530d27bc6eedfe45f840599f8 \
+ --hash=sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4 \
+ --hash=sha256:c825661dfcf843119ab57cdcac0df7a48e168764c66917bc74f9a42ecb096da9 \
+ --hash=sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a \
+ --hash=sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757 \
+ --hash=sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2 \
+ --hash=sha256:d08952c0f14eb56d9dad72a2e17773b5f709c55b28635822d18c4adf38680833 \
+ --hash=sha256:d22a083497d2f7d06a57172c5b60ee66cedcf304fde5226d4dfdc94f6180f5b1 \
+ --hash=sha256:d2478bd3b2ead3962a484fb802891be40d10049fb74f83e09cb4463fad023fea \
+ --hash=sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45 \
+ --hash=sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa \
+ --hash=sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539 \
+ --hash=sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775 \
+ --hash=sha256:d74bcf1cdd8ac8267fb216473ce6b112efa07b163536288094541415084d131c \
+ --hash=sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636 \
+ --hash=sha256:d867cefea33acad8e33a3eb408cca7889a9cf999bd5433d962089d5a13b6e75f \
+ --hash=sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45 \
+ --hash=sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8 \
+ --hash=sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258 \
+ --hash=sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2 \
+ --hash=sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834 \
+ --hash=sha256:dc7f6aca0bdac5e6520c8b6769bda69315fe7cb57f69885f115bc8ca02d1d022 \
+ --hash=sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142 \
+ --hash=sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80 \
+ --hash=sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040 \
+ --hash=sha256:e46a37ea7fcf9ae01d71b2e5ece19f1565987f3e308394b829197cbefc061f92 \
+ --hash=sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd \
+ --hash=sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14 \
+ --hash=sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40 \
+ --hash=sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4 \
+ --hash=sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0 \
+ --hash=sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396 \
+ --hash=sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa \
+ --hash=sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50 \
+ --hash=sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d \
+ --hash=sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053 \
+ --hash=sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698 \
+ --hash=sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b \
+ --hash=sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49 \
+ --hash=sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888 \
+ --hash=sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e \
+ --hash=sha256:ffdd7ac514301d0a67f7c23b9f2b431ef909a3c3dd6c3766668d0a6f5900c94e
# via
# ocotilloapi
# requests
@@ -613,9 +692,9 @@ fastapi-pagination==0.15.16 \
--hash=sha256:739a4e904729dc01e03ce95e260ed9be050d48a3659be8463e5c4fcdd0cf25b0 \
--hash=sha256:86dc73620812d47c297a7b8baca4eba2bac5d3f1d73aa75dc6e3bb12c0b803f7
# via ocotilloapi
-filelock==3.32.2 \
- --hash=sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82 \
- --hash=sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8
+filelock==3.32.3 \
+ --hash=sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f \
+ --hash=sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09
# via pygeoapi
flask==3.1.3 \
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
@@ -750,86 +829,86 @@ googleapis-common-protos==1.75.1 \
# via
# google-api-core
# ocotilloapi
-greenlet==3.5.4 \
- --hash=sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20 \
- --hash=sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c \
- --hash=sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994 \
- --hash=sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8 \
- --hash=sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d \
- --hash=sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9 \
- --hash=sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f \
- --hash=sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809 \
- --hash=sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c \
- --hash=sha256:188e4d142f243051d92a1f5c244a741da02dddc070a0620c842804d7b56d008c \
- --hash=sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72 \
- --hash=sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3 \
- --hash=sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02 \
- --hash=sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c \
- --hash=sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c \
- --hash=sha256:2cdaadc3d31445a8f782bde3cd37e49a2c2a9c6da6daf76a3e34c683b271a3c7 \
- --hash=sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec \
- --hash=sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c \
- --hash=sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686 \
- --hash=sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861 \
- --hash=sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8 \
- --hash=sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0 \
- --hash=sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4 \
- --hash=sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9 \
- --hash=sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3 \
- --hash=sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9 \
- --hash=sha256:4ab9f0704bccf6d3b38e0d2130b7b33271cff11453690da074fa280c3aa8e8e7 \
- --hash=sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7 \
- --hash=sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd \
- --hash=sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3 \
- --hash=sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2 \
- --hash=sha256:69173331fbc5d64bfac0065d7e22c39cfcd089e9b18d125bdcd5079363b09616 \
- --hash=sha256:70bdfacdc183dac838b2a0aaff2dd6134a457c52fe68a9c6bbab435483d2b9df \
- --hash=sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf \
- --hash=sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0 \
- --hash=sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a \
- --hash=sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f \
- --hash=sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22 \
- --hash=sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356 \
- --hash=sha256:89f3738167bab8c1084b94e23023d41d247117ac149fa0fbcb5bd4cf6262b353 \
- --hash=sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e \
- --hash=sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7 \
- --hash=sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5 \
- --hash=sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8 \
- --hash=sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde \
- --hash=sha256:a2d614cb2372c7101a12ea8b96dd56f81c986d247c5a73db67063f3ed1ca4a52 \
- --hash=sha256:ac5bf81d79d2c8eeb2ef6359b2e1687a1e9ebf46c2b1f970da9a9255df51d190 \
- --hash=sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05 \
- --hash=sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937 \
- --hash=sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867 \
- --hash=sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d \
- --hash=sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf \
- --hash=sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f \
- --hash=sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd \
- --hash=sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da \
- --hash=sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071 \
- --hash=sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88 \
- --hash=sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17 \
- --hash=sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c \
- --hash=sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66 \
- --hash=sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb \
- --hash=sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c \
- --hash=sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25 \
- --hash=sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0 \
- --hash=sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927 \
- --hash=sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6 \
- --hash=sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c \
- --hash=sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59 \
- --hash=sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb \
- --hash=sha256:e9a5e3406e3ed8125ae1a3b37c12f3434e2b1f0fa053197c5557895b4fb09606 \
- --hash=sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef \
- --hash=sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3 \
- --hash=sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da \
- --hash=sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132 \
- --hash=sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7 \
- --hash=sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f \
- --hash=sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2 \
- --hash=sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f \
- --hash=sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667
+greenlet==3.5.5 \
+ --hash=sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537 \
+ --hash=sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39 \
+ --hash=sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277 \
+ --hash=sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41 \
+ --hash=sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2 \
+ --hash=sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d \
+ --hash=sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53 \
+ --hash=sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e \
+ --hash=sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502 \
+ --hash=sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5 \
+ --hash=sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc \
+ --hash=sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759 \
+ --hash=sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f \
+ --hash=sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b \
+ --hash=sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1 \
+ --hash=sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5 \
+ --hash=sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769 \
+ --hash=sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0 \
+ --hash=sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f \
+ --hash=sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da \
+ --hash=sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76 \
+ --hash=sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3 \
+ --hash=sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e \
+ --hash=sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476 \
+ --hash=sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e \
+ --hash=sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380 \
+ --hash=sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef \
+ --hash=sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18 \
+ --hash=sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b \
+ --hash=sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272 \
+ --hash=sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0 \
+ --hash=sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053 \
+ --hash=sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07 \
+ --hash=sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387 \
+ --hash=sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52 \
+ --hash=sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed \
+ --hash=sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95 \
+ --hash=sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c \
+ --hash=sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad \
+ --hash=sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f \
+ --hash=sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db \
+ --hash=sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328 \
+ --hash=sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8 \
+ --hash=sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71 \
+ --hash=sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc \
+ --hash=sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864 \
+ --hash=sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0 \
+ --hash=sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1 \
+ --hash=sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b \
+ --hash=sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667 \
+ --hash=sha256:86c5113d698cb8d927b2750bb1f1d59eefe3a37e0e0217491aee29a7f84ef52c \
+ --hash=sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c \
+ --hash=sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926 \
+ --hash=sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc \
+ --hash=sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd \
+ --hash=sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007 \
+ --hash=sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6 \
+ --hash=sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56 \
+ --hash=sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0 \
+ --hash=sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b \
+ --hash=sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53 \
+ --hash=sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c \
+ --hash=sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c \
+ --hash=sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474 \
+ --hash=sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa \
+ --hash=sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61 \
+ --hash=sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206 \
+ --hash=sha256:c69bed34470abfcd456984fdadaa18e62169af4480335c45f3c32d1d9c12e638 \
+ --hash=sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9 \
+ --hash=sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874 \
+ --hash=sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d \
+ --hash=sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8 \
+ --hash=sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae \
+ --hash=sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0 \
+ --hash=sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773 \
+ --hash=sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4 \
+ --hash=sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552 \
+ --hash=sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42 \
+ --hash=sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b
# via
# ocotilloapi
# sqlalchemy
@@ -1049,51 +1128,73 @@ multidict==6.7.1 \
# aiohttp
# ocotilloapi
# yarl
-numpy==2.5.1 \
- --hash=sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2 \
- --hash=sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d \
- --hash=sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1 \
- --hash=sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b \
- --hash=sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd \
- --hash=sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077 \
- --hash=sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a \
- --hash=sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e \
- --hash=sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277 \
- --hash=sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6 \
- --hash=sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75 \
- --hash=sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7 \
- --hash=sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1 \
- --hash=sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9 \
- --hash=sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21 \
- --hash=sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca \
- --hash=sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0 \
- --hash=sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb \
- --hash=sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d \
- --hash=sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75 \
- --hash=sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74 \
- --hash=sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf \
- --hash=sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0 \
- --hash=sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8 \
- --hash=sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af \
- --hash=sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a \
- --hash=sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4 \
- --hash=sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22 \
- --hash=sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3 \
- --hash=sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1 \
- --hash=sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b \
- --hash=sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1 \
- --hash=sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373 \
- --hash=sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95 \
- --hash=sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6 \
- --hash=sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09 \
- --hash=sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9 \
- --hash=sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438 \
- --hash=sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2 \
- --hash=sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7 \
- --hash=sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace \
- --hash=sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3 \
- --hash=sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2 \
- --hash=sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107
+numpy==2.5.2 \
+ --hash=sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a \
+ --hash=sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f \
+ --hash=sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7 \
+ --hash=sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0 \
+ --hash=sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3 \
+ --hash=sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c \
+ --hash=sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce \
+ --hash=sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8 \
+ --hash=sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1 \
+ --hash=sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4 \
+ --hash=sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee \
+ --hash=sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740 \
+ --hash=sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98 \
+ --hash=sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710 \
+ --hash=sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee \
+ --hash=sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68 \
+ --hash=sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf \
+ --hash=sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8 \
+ --hash=sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf \
+ --hash=sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b \
+ --hash=sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884 \
+ --hash=sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03 \
+ --hash=sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69 \
+ --hash=sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4 \
+ --hash=sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842 \
+ --hash=sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65 \
+ --hash=sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080 \
+ --hash=sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e \
+ --hash=sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e \
+ --hash=sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414 \
+ --hash=sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59 \
+ --hash=sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8 \
+ --hash=sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617 \
+ --hash=sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4 \
+ --hash=sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb \
+ --hash=sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251 \
+ --hash=sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d \
+ --hash=sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2 \
+ --hash=sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab \
+ --hash=sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657 \
+ --hash=sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15 \
+ --hash=sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9 \
+ --hash=sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8 \
+ --hash=sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323 \
+ --hash=sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788 \
+ --hash=sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc \
+ --hash=sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56 \
+ --hash=sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1 \
+ --hash=sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d \
+ --hash=sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec \
+ --hash=sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2 \
+ --hash=sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e \
+ --hash=sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7 \
+ --hash=sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26 \
+ --hash=sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514 \
+ --hash=sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860 \
+ --hash=sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a \
+ --hash=sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1 \
+ --hash=sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab \
+ --hash=sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba \
+ --hash=sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12 \
+ --hash=sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6 \
+ --hash=sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e \
+ --hash=sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac \
+ --hash=sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb \
+ --hash=sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f
# via
# ocotilloapi
# pandas
@@ -1145,9 +1246,9 @@ pg8000==1.31.5 \
--hash=sha256:0af2c1926b153307639868d2ee5cef6cd3a7d07448e12736989b10e1d491e201 \
--hash=sha256:46ebb03be52b7a77c03c725c79da2ca281d6e8f59577ca66b17c9009618cae78
# via ocotilloapi
-phonenumbers==9.0.36 \
- --hash=sha256:60ca2a6870d2532d6e960105790e85e9d69270ede81746d10cf57f5e437b93ec \
- --hash=sha256:c16a5b95178345ec2df1954578a4ac09e937443b2ee992dd7b15a8e8c81f4acf
+phonenumbers==9.0.37 \
+ --hash=sha256:55ae8243c1e2fe8ae2548d979e57155d9ae0f6084f8a1d6884050b143aff0a7c \
+ --hash=sha256:8923ab413c575b27da2815c9bef48290181aab565398b5e7e23cc7ace212a293
# via ocotilloapi
pillow==12.3.0 \
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
@@ -1656,32 +1757,42 @@ pyyaml==6.0.3 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via 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
+rasterio==1.5.1 \
+ --hash=sha256:00c78697cb565e97f99deb485fc7f23f28ff30e857124872ef0c5a3e83dd7e47 \
+ --hash=sha256:01f927ee7ef08cbc111b34092df266308fd2c64fad2e21a97c991973eb8e0c25 \
+ --hash=sha256:0898355908ed3614ba1415532432cf4a1a308b189b2c9d8307a26a44bcb1713b \
+ --hash=sha256:11e2e9c68971beeec603c2613fa7740cf0a02dd026ef1aaa75d1cdf4246fbde6 \
+ --hash=sha256:3aa1a3441587341b78558b5359aa5d2694b45a911d7b5df23af726420e420bce \
+ --hash=sha256:546ca44c4417772a48c5224b037f10330a75cde74b429f07ac43838e20f9b2c9 \
+ --hash=sha256:55e18b8a6a7f4a9769942d48a1545c5a97920cf841fe5e4099032ba855bdf95c \
+ --hash=sha256:5933e56b15ef29fcbec370a2e0b5ac39b33023d85a3f5b01cdf0cbe4231c6204 \
+ --hash=sha256:5ff9db698a88e716254fb0788768f75d7b58532c8253674261fa05abd40f621e \
+ --hash=sha256:60d74d11f290510639046c447256085eae0a925acdfe07e37f7436fb97cb9f46 \
+ --hash=sha256:6fbafe970d44ec06179c7884cdb160a90311e3d19da25bff970fab06123f0201 \
+ --hash=sha256:821d49afd18498fb0918b72961b1b667193c64edf8cc484081c522d10b33980d \
+ --hash=sha256:82719d4de76f3bafb165e932ad997b07116aa08621db079750ef1031dc514e11 \
+ --hash=sha256:833511f045ca49dafbf25da4762a0b19dcf5d046ded8b318c094363e2d65f474 \
+ --hash=sha256:8683074903018918341908e714f792ed5d696bad441ecec1787de8eb896573cd \
+ --hash=sha256:9093a9277559ac0f954d9a0266886bc4037e3ffd7a40837f6ea6486a96c8c258 \
+ --hash=sha256:937944c445d4ea4d6969e9dbfcf305143c05fc4df2f8fadfa360cb2d0f65d7b0 \
+ --hash=sha256:94ae0fe4c0eb031da99614a1a1cd27819f900f86bc84976cd9f713eee05584a3 \
+ --hash=sha256:a270366c4f44f7bb4367d7068b6de08c56b2b04049e3dfcd06d451141fee3a7f \
+ --hash=sha256:a685d2e97684b275b12859f9e0b95a8f511a51a2ac51b284428c6f50f1bcfd17 \
+ --hash=sha256:ac450a2c1e6de990eaaee347ebceca160b1531c9f13c5db489d0e3113ea13984 \
+ --hash=sha256:b117209922784ef7948f702a0e9eb26be744f4b24d31f5eb97c45c0f04e2e4c3 \
+ --hash=sha256:b3686865af2c67114f5dfb67df483db61bccad4078083a3dad481ea035e5d701 \
+ --hash=sha256:bf5d2cf43791651522fedb284cc08398f96fecc018dff24d7fc97965c909ab3a \
+ --hash=sha256:c0cd79ed52481d55395f1d3def075ff45173ed07ccd238fddeb0c63522b3d137 \
+ --hash=sha256:c1b6ae15f4ccad704f1fe8417da5c2250145c7bcdb91acb53833bf5aefdd9e48 \
+ --hash=sha256:c209e670abf0f30695a784c4f0170366462b9a3eaea3e49122ab2ce81799460b \
+ --hash=sha256:c361ed93cb8ceb314bf42d98815f2b6dd0254f7367d2b932435a8cf0aeaecf96 \
+ --hash=sha256:c8702772d6e91c0578d84af88de7d4926c48b0b6eca33d0660e90d7f37c06bf1 \
+ --hash=sha256:d8064e9b2820c3f1027399f410d8cdbfb57bdedfad46e5381afb527a9b6f74a2 \
+ --hash=sha256:e31b65231a631b0b59471aeae4423167d42f88da0c5ee5a519dc07deb7a4666b \
+ --hash=sha256:e4ea970d39f0b134fc91141d767b3c49bc2dea38a32f0b036fbdd5ec2e8ca457 \
+ --hash=sha256:e863b614cadad435a4394792b6cdc84324b15197629ad5a20c8d56cbc4eeff92 \
+ --hash=sha256:ee01dc4b114a21078af87da916816c6c62ba5b5d216725f148ae695ef4deebf2 \
+ --hash=sha256:fbb933659035f0aca32e4dc0dc022c7162b4f6598b15366c5b19b4a7e65c1991
# via pygeoapi
referencing==0.37.0 \
--hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \
@@ -1887,15 +1998,15 @@ rsa==4.9.1 \
# via
# ocotilloapi
# python-jose
-scramp==1.4.16 \
- --hash=sha256:836bcecdc8843af76b35e11adbe818e3d7934d0d25aa48fd2e8cde362a2c6a9b \
- --hash=sha256:9e6148fca008ab6c6ce84658aeefd03d5d7f269d32fe08d07e134dae089e937a
+scramp==1.4.17 \
+ --hash=sha256:28970f29ebc33df47f9975c805e5e5a360effe5b31045e607d64b3b60370dba1 \
+ --hash=sha256:a4e3fd2e8169461a28a13777a166d3da94274454f0714a7d3023fee124474ac8
# via
# ocotilloapi
# pg8000
-sentry-sdk==2.66.1 \
- --hash=sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6 \
- --hash=sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc
+sentry-sdk==2.68.0 \
+ --hash=sha256:538e56c2d03679d42f7c0cb5f1af73a7a510b00abc7e296c13ac49b107b713a4 \
+ --hash=sha256:648c58e9887311a03470a41539e24bdbbf64a30ca4f5336f7e3dcc87276400b3
# via ocotilloapi
shapely==2.1.2 \
--hash=sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9 \
@@ -1949,30 +2060,59 @@ sniffio==1.3.1 \
--hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
--hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
# via ocotilloapi
-sqlalchemy==2.0.51 \
- --hash=sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23 \
- --hash=sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8 \
- --hash=sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72 \
- --hash=sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5 \
- --hash=sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e \
- --hash=sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2 \
- --hash=sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f \
- --hash=sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d \
- --hash=sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54 \
- --hash=sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195 \
- --hash=sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522 \
- --hash=sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491 \
- --hash=sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400 \
- --hash=sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07 \
- --hash=sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7 \
- --hash=sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9 \
- --hash=sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7 \
- --hash=sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499 \
- --hash=sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d \
- --hash=sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b \
- --hash=sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5 \
- --hash=sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d \
- --hash=sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de
+sqlalchemy==2.0.52 \
+ --hash=sha256:11560064cc4696e772298b6221ede59e646386d9f2a85d549365473b972f7850 \
+ --hash=sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee \
+ --hash=sha256:1b92a1e23ed40022081217b40d2d1feba4f77064e69ef4f39f68bcbbd148452a \
+ --hash=sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca \
+ --hash=sha256:2e15b1d1116a64fc399b8c2694a83f3e792fdc58df28514a81e1dc4f8cf22729 \
+ --hash=sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43 \
+ --hash=sha256:2f5fa2b2aca75d2c7f36db3a8dd04717b6fbfd1a964fb32bdeae16698e475ab3 \
+ --hash=sha256:2f9eccf8793c8c3f8dd2dfd11b9e400cb27d1d19370ef732b66017e212107822 \
+ --hash=sha256:309cc8ba50fc5d2174189dfcd49cdf7aa711f8346afcff19f2642ae4fc449c14 \
+ --hash=sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee \
+ --hash=sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89 \
+ --hash=sha256:3c95c3044edddb65e4a2f7194ec52ca5a9736f72d33ca3a6fa4196aedcc689fd \
+ --hash=sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9 \
+ --hash=sha256:4699dbb8d396d199e7e78fd4d525e3ad3d6008a9c8c0160b87e74c606c2c3736 \
+ --hash=sha256:46f0c46f0d360d727b84660b26c62b295d82306ec2c82b701e97747d2c6dcbe1 \
+ --hash=sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd \
+ --hash=sha256:4b89e93bb89eabdbea9d5d3fa2d6cc6544e733c33064339f91e5292480cf130e \
+ --hash=sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8 \
+ --hash=sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97 \
+ --hash=sha256:5f8438a98d49424acf69d0d53c0a522951dfe49a6f2d86417fbb37ad3066ab43 \
+ --hash=sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c \
+ --hash=sha256:6c1b7ed45bf87b214e0a9def9c2313949067efe6269db5ef18d542ee13250af7 \
+ --hash=sha256:765f439da5bc8696973bc0c8a31fae0912ac3ff1cb9d66246a6b2728ee4fbbc8 \
+ --hash=sha256:77a247d3fd179f6583171e7e0e98f40dc6642ed4f655557515a5a7e25923e9a4 \
+ --hash=sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608 \
+ --hash=sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437 \
+ --hash=sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b \
+ --hash=sha256:8cf993f065bc04caa5000b339e8d9d6f3d9d00251511f850147c516c9e07115f \
+ --hash=sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1 \
+ --hash=sha256:9255ceb65a80c1b001129060b63ee776a2e9c288be3b662be36dfbb888fffdcd \
+ --hash=sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1 \
+ --hash=sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751 \
+ --hash=sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d \
+ --hash=sha256:a7438774e1091192fc50a2bd8ceff5c596912d00ecd46587e88effdea7826101 \
+ --hash=sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057 \
+ --hash=sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2 \
+ --hash=sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e \
+ --hash=sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15 \
+ --hash=sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727 \
+ --hash=sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582 \
+ --hash=sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d \
+ --hash=sha256:cce4922535db73f9dbb91e3db2b3e851ac629467fd1ebd8e354a60e369521c63 \
+ --hash=sha256:cd9206024b8602e7518bbaf44016c29e0045722f09328d8e654941023920d0b3 \
+ --hash=sha256:cef328349452ae152637df4d11ce5a0919ecdf0a363e16c830c3518ee33bde72 \
+ --hash=sha256:de89de5b5798cafdd7ef7b7b804acec246d6152922128fd9d156cd1701271aff \
+ --hash=sha256:df8f213ceb485d8227b74935eb87ba0d80169a8401eba7835da6e30d6727dac4 \
+ --hash=sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e \
+ --hash=sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c \
+ --hash=sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf \
+ --hash=sha256:f1c850792a3b25a3ad74dade3f05e4f402cdebfea27438bcadafaa1617f77bcc \
+ --hash=sha256:f2b09029ef6f260409eefa5dc2b8276f6c3d7b892bfb50d50e8f852257d4a6b4 \
+ --hash=sha256:f4d4f7afc682961dc567db70e00a7b5bd81ccd3743c46199b0257f0744902dde
# via
# alembic
# geoalchemy2
@@ -1999,9 +2139,9 @@ sqlparse==0.6.0 \
--hash=sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9 \
--hash=sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f
# via ocotilloapi
-starlette==1.4.1 \
- --hash=sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19 \
- --hash=sha256:b7332de6e9375593a29ba9eee1e6ecfeb3eb2043e2e19a13b4b71da73ff35540
+starlette==1.6.0 \
+ --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
+ --hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
# via
# apitally
# fastapi
@@ -2039,9 +2179,9 @@ typing-extensions==4.16.0 \
# pygeoif
# sqlalchemy
# typing-inspection
-typing-inspection==0.4.2 \
- --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \
- --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464
+typing-inspection==0.4.4 \
+ --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \
+ --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
# via
# fastapi
# ocotilloapi
@@ -2068,9 +2208,9 @@ utm==0.9.0 \
--hash=sha256:1c8ffa6032631379374ceef05e6fea0ad42e9f09be0c3f91f7cc1b23f27be8a7 \
--hash=sha256:767592281e457dfacd71323ac69ff38e2f290d74526af91ca0924637de0e1d53
# via ocotilloapi
-uvicorn==0.52.1 \
- --hash=sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd \
- --hash=sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a
+uvicorn==0.52.3 \
+ --hash=sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c \
+ --hash=sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58
# via ocotilloapi
werkzeug==3.1.8 \
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
diff --git a/uv.lock b/uv.lock
index 4966d646c..e3dbf53cd 100644
--- a/uv.lock
+++ b/uv.lock
@@ -137,16 +137,16 @@ wheels = [
[[package]]
name = "alembic"
-version = "1.19.0"
+version = "1.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fb/01/a48dab7827ac4421272399f7ed9a2ec17edd12c8bcde4417bd7b6821b71a/alembic-1.19.0.tar.gz", hash = "sha256:6487c612fc719dcfa22b17d2dd5b2b458929641e6aa2f0b65b135727f5e6d501", size = 2069906, upload-time = "2026-08-04T18:57:04.599Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/16/2b/e4153978368de59918115c9e01d3ebf58a558a7285efa7e960c383c4b59a/alembic-1.19.1.tar.gz", hash = "sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648", size = 2070816, upload-time = "2026-08-08T16:32:01.565Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/34/79/59ab6f1fe72eee229de91aa393225389a85df12a0fbbbfa264efcf6d7872/alembic-1.19.0-py3-none-any.whl", hash = "sha256:cf839d3849116aab3cc047e09c6968b9bd6b2fde61b6bb7c1e97352fe5503580", size = 265738, upload-time = "2026-08-04T18:57:06.219Z" },
+ { url = "https://files.pythonhosted.org/packages/20/89/e62cc37b69ad357cc8ecd6e7367f5245f523d3cbb338a66197212bdf6749/alembic-1.19.1-py3-none-any.whl", hash = "sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be", size = 265946, upload-time = "2026-08-08T16:32:03.153Z" },
]
[[package]]
@@ -495,50 +495,117 @@ wheels = [
[[package]]
name = "charset-normalizer"
-version = "3.4.9"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" },
- { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" },
- { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" },
- { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" },
- { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" },
- { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" },
- { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" },
- { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" },
- { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" },
- { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" },
- { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" },
- { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" },
- { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" },
- { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
- { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
- { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
- { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
- { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
- { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
- { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
- { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
- { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
- { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
- { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
- { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
- { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
- { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
- { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
- { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
- { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
- { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
- { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
- { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
- { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
- { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
- { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
- { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
- { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
- { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
- { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cb/31/4971872b3ed8715346231fb6eb4da8fcba65a4143c189db151ee28a2812b/charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e", size = 169295, upload-time = "2026-08-12T14:35:31.624Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/be/cc7b7b6fc41984902c0d31b06f5d9297e67705c1dae9352608e5540fad09/charset_normalizer-3.5.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144", size = 211050, upload-time = "2026-08-12T14:32:47.81Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/ae/e3ec8f17313609f43f7b323012fdb1ee37b83432277ca4eceba83e00366c/charset_normalizer-3.5.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f", size = 222768, upload-time = "2026-08-12T14:32:49.027Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/50/9f9c0d7ccc1512d49e27a0e7c12c58ec71dfe91698fa4326f058c33e1f1b/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc", size = 193907, upload-time = "2026-08-12T14:32:50.414Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/1d/cfe7b745ef7f4c3b7214581955b5a0869ba2ac551a58fc11036281ae167c/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991", size = 197135, upload-time = "2026-08-12T14:32:51.605Z" },
+ { url = "https://files.pythonhosted.org/packages/28/55/30fafdcfca9ba616bc394240545e4cd52f4f66dea43ded81b7d2d5274fde/charset_normalizer-3.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64", size = 339892, upload-time = "2026-08-12T14:32:52.82Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/08/bdca5fc2bdc36ee443673dc7d12b23885a5a7b282bef85a1a4c3b325b40e/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b", size = 239439, upload-time = "2026-08-12T14:32:54.058Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/9e/506c8d7a7722bba7c8cdd78c1b5ef23bda92bfbe0b3e28ea84673d519a0f/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee", size = 227896, upload-time = "2026-08-12T14:32:55.326Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/1a/dd828f2b1d6f4bf10821b9a74d866be05ffcdbfcddfc501d6fe6428762a7/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d", size = 262548, upload-time = "2026-08-12T14:32:56.484Z" },
+ { url = "https://files.pythonhosted.org/packages/be/81/196d26f6bd78b93e0d451b69082a71027ceeddd4b0be9170b81bb038f824/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f", size = 259986, upload-time = "2026-08-12T14:32:57.661Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/6a/5b964a1eb0f9075ecd45083eeb21aaec215334f98bac3d400302ea73875d/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be", size = 249853, upload-time = "2026-08-12T14:32:59.123Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/b4/aee3a9d82edd0e931091ef3e9f03e46491ae3590e96e998d0975dadbe17c/charset_normalizer-3.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9", size = 244217, upload-time = "2026-08-12T14:33:00.341Z" },
+ { url = "https://files.pythonhosted.org/packages/22/3e/33f72ca11c1b619b220fd9f35905ebd171cbd0e0470f2357e467b9e861ee/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715", size = 241307, upload-time = "2026-08-12T14:33:01.589Z" },
+ { url = "https://files.pythonhosted.org/packages/78/27/6029dccba958621c7f3a65136f87c5512d712aef9e890f09512cc171bd03/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08", size = 233305, upload-time = "2026-08-12T14:33:02.866Z" },
+ { url = "https://files.pythonhosted.org/packages/18/d7/691c967be459153fe9faf49bf78bc95639ef8bf6dd008f38cc6389a349eb/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2", size = 263465, upload-time = "2026-08-12T14:33:04.166Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/2d/9202221be5c90b2a835924191e362690ed8dc8c7d6606100c2bd03fe0f8c/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d", size = 245060, upload-time = "2026-08-12T14:33:05.325Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/9a/298772fd0a0cbccadf36451a1cd7eef4b66a11e99b4a7f6fafc47cc62c75/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d", size = 261091, upload-time = "2026-08-12T14:33:06.475Z" },
+ { url = "https://files.pythonhosted.org/packages/82/3b/1a11fe66e555dbe2f5714ade6ba74fa29edc9155d9cf1001d4d6ed096aa7/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888", size = 251857, upload-time = "2026-08-12T14:33:07.784Z" },
+ { url = "https://files.pythonhosted.org/packages/17/fc/73b817e8af3f1d25ec5cf458d405abba5a144cf9812238a61530f5eac186/charset_normalizer-3.5.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d", size = 139745, upload-time = "2026-08-12T14:33:09.123Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/fb/ddb66303c86f7dc5043a457dad9fa82b4d6d0cb97094f9bcdde21693fd58/charset_normalizer-3.5.0-cp313-cp313-win32.whl", hash = "sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e", size = 177217, upload-time = "2026-08-12T14:33:10.305Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/88/6018cc8d76ea2b7cb02918f37e23e86c261d1a102713d7e88d2cfb8b211c/charset_normalizer-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a", size = 198896, upload-time = "2026-08-12T14:33:11.51Z" },
+ { url = "https://files.pythonhosted.org/packages/20/2e/04c0bbfc8d9abf91959f7a3d207d45cbf63a8116984caae2381890019bb5/charset_normalizer-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b", size = 179193, upload-time = "2026-08-12T14:33:12.726Z" },
+ { url = "https://files.pythonhosted.org/packages/43/14/d098868dac5ff27e0258f548b1c74c6484be528384965d8fcf8fc6a4011d/charset_normalizer-3.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2", size = 211664, upload-time = "2026-08-12T14:33:14.153Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/da/a944b32a46601ae5a4c3499e8d64ecd14fe82313f00da74dcdf00273a0b4/charset_normalizer-3.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636", size = 224375, upload-time = "2026-08-12T14:33:15.472Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/db/eabb5996be2f529744755e7b2fc9396eff4a64961f034e7fd49d54b9afb2/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b", size = 194364, upload-time = "2026-08-12T14:33:16.607Z" },
+ { url = "https://files.pythonhosted.org/packages/78/65/4ad3c5be108930310d8003f5602861d5b89f728293b9f09c3a4837f7ba10/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45", size = 197643, upload-time = "2026-08-12T14:33:17.88Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/39/8fee3201b98d52289be60a775797d69be05a04fb6cfb48c1587dad33e649/charset_normalizer-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6", size = 341384, upload-time = "2026-08-12T14:33:19.239Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/dd/9e757101d1f76c35c0643684ba499ac3a181fb2b264c68174bf727d627e8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73", size = 241637, upload-time = "2026-08-12T14:33:20.619Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/e4/7857023015400bc4aa0a82fbcca29fa2dc7ec25f971a130764cb2dc7a589/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8", size = 226170, upload-time = "2026-08-12T14:33:21.773Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/ae/8b52935b304f7b6bbf33151ed2b75266b09aa4b6f8f04230d948885b2577/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b", size = 265093, upload-time = "2026-08-12T14:33:22.999Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/78/6e838f6bb059f2c0afc60a4e7f294252f043c254656ad4114c50302cae4d/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd", size = 262789, upload-time = "2026-08-12T14:33:24.214Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/08/189b27e51fddc9d6b3695331da0e31792c1d88b953ad854e57f06e9b2cc8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c", size = 250580, upload-time = "2026-08-12T14:33:25.707Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/55/64854e99b25841f83e8e37d9df2f3d1f96f693439f80e5fabd542a7e47ab/charset_normalizer-3.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458", size = 245008, upload-time = "2026-08-12T14:33:26.971Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/01/7720c904fa635d4260b4dced6029cf3d298c57b26741365d5a8d28c54043/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700", size = 243892, upload-time = "2026-08-12T14:33:28.237Z" },
+ { url = "https://files.pythonhosted.org/packages/70/50/7bfcb327631d4870c720872b548745f6ec8baa044d51c21b5d1d32ac4e3a/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a", size = 230996, upload-time = "2026-08-12T14:33:29.511Z" },
+ { url = "https://files.pythonhosted.org/packages/24/51/40c45d6d940c04005ed721aa54bdebf1ebb2930f8a2ae537e8d60484fb27/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec", size = 265834, upload-time = "2026-08-12T14:33:30.689Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d4/ef7a227ef89d215b47f9df79c3966610b17faa13bb2f236989207a631622/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3", size = 245544, upload-time = "2026-08-12T14:33:31.859Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a9/a4ca9156964ded61c7718eba410ce11be2fd2b263fda4bcf08367b6578cd/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0", size = 264110, upload-time = "2026-08-12T14:33:33.13Z" },
+ { url = "https://files.pythonhosted.org/packages/38/6a/838364bb8702229c6e5f8b23f80ff0f052a12dfaf3113a12fd6acbe92a44/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb", size = 252303, upload-time = "2026-08-12T14:33:34.98Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/5f/d88032edce951f499a2321cf7ae0d35a043c74be12bc22d81084cc7afbcc/charset_normalizer-3.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053", size = 139964, upload-time = "2026-08-12T14:33:36.195Z" },
+ { url = "https://files.pythonhosted.org/packages/37/ae/1c4a46b6b00d1c34d2ee355ef99ad6173674166800d1af0f05f85028d513/charset_normalizer-3.5.0-cp314-cp314-win32.whl", hash = "sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e", size = 179790, upload-time = "2026-08-12T14:33:37.356Z" },
+ { url = "https://files.pythonhosted.org/packages/01/51/f94dcf34fa8eba48c1fb89b6490a5f1426e19488fe5f38aac6c648c99057/charset_normalizer-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4", size = 203723, upload-time = "2026-08-12T14:33:38.639Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/ba/91d386870b5d9e4b0d8c4034f63877cc2e47b99c81ef05f3e6d42bf9a53f/charset_normalizer-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834", size = 183423, upload-time = "2026-08-12T14:33:39.899Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/c9/534ecb17b7fb95f9052c4a44cf316316a27d4a8f73e8475ff55e778dcdd7/charset_normalizer-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6", size = 368967, upload-time = "2026-08-12T14:33:41.093Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/b2/ad7c3242d7fe55cd55126c22c65cb1b49779782cdf8932fd01d12232d86a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f", size = 239478, upload-time = "2026-08-12T14:33:42.428Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/62/77f0b850048e430fc350ec58876b0c020f5c8d0d3956fd1a4d6ae2fa292f/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1", size = 227036, upload-time = "2026-08-12T14:33:43.635Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/ba/47d951e1a51dddbaad0a1410baf49fb1d897ceb00281568f1183b79bce9a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa", size = 260772, upload-time = "2026-08-12T14:33:44.96Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/61/8c7ff4c81b2a88271126acf4b83ab3e31f6d63868b0f01d331eaa0f9cb67/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4", size = 259273, upload-time = "2026-08-12T14:33:46.185Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/fd/36129689be08dc287b951306946657ff70d76e287dd57018861f86d0e474/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6", size = 248086, upload-time = "2026-08-12T14:33:47.54Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/f8/bcae67f994c8fd31dda445e5ebf84045823c31443fe46f0e9ee6aca99aa0/charset_normalizer-3.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0", size = 242671, upload-time = "2026-08-12T14:33:48.746Z" },
+ { url = "https://files.pythonhosted.org/packages/61/92/0472cdad1061c2f0e4d3aee29973eb6e81bb8fe256ff2860cf115b15f1c9/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0", size = 241311, upload-time = "2026-08-12T14:33:50.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/89/04a03de5d27c77c624d9fcf6287073754bd438df1b58cb7d030c57c2824d/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19", size = 229898, upload-time = "2026-08-12T14:33:51.523Z" },
+ { url = "https://files.pythonhosted.org/packages/42/a2/639c4278adcb7ed1f4db608dd9ac19b6774fa2285a96b1c0bdb9c124ccbd/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50", size = 262852, upload-time = "2026-08-12T14:33:52.924Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/95/02e34c97bedfd0c5574efb9179c850591acc7f967ba039ed8dd29d332b73/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa", size = 242913, upload-time = "2026-08-12T14:33:54.18Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/64/0946aeab6462dad9f160a50dfb4704d3f58a5ee708f085abc2105fbbff0c/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9", size = 257938, upload-time = "2026-08-12T14:33:55.802Z" },
+ { url = "https://files.pythonhosted.org/packages/79/77/36787d41ead124746506a4425c729f4f17c68280af8a6a5baa0a598cae86/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40", size = 249467, upload-time = "2026-08-12T14:33:57.081Z" },
+ { url = "https://files.pythonhosted.org/packages/65/10/d9f6c5589cd24198d4ce6cd2948191c18e657272f433e5a00d258d9f5c22/charset_normalizer-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6", size = 190624, upload-time = "2026-08-12T14:33:58.449Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/81/43e0584a802051a22c725795ebe1df78263abc7de858eef6cdc9b36637e9/charset_normalizer-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3", size = 215902, upload-time = "2026-08-12T14:33:59.753Z" },
+ { url = "https://files.pythonhosted.org/packages/30/f3/af6a1160fef0eac4510d035241e11eccf78e5350e4cd4de79e79fe02a5e5/charset_normalizer-3.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897", size = 193452, upload-time = "2026-08-12T14:34:01.017Z" },
+ { url = "https://files.pythonhosted.org/packages/42/a4/dee470afb7a55c4f78b6fef37306c51fed17ebf94dbe530798c91d394350/charset_normalizer-3.5.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce", size = 341595, upload-time = "2026-08-12T14:34:02.4Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/32/9c3126dc429c6d9d7f79c52681a7c4453ed20a26267c9a8275d7ab620aba/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757", size = 242177, upload-time = "2026-08-12T14:34:03.741Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/9d/5b616a887301ff4cc0916b39ba44257390d3da80deeed6e8b6f2f26b14a8/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396", size = 236730, upload-time = "2026-08-12T14:34:04.991Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/d6d3e70be93ebe5fabef65e4c7ac113e1d1705cbaeb5fb72467e713aca17/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44", size = 265158, upload-time = "2026-08-12T14:34:06.235Z" },
+ { url = "https://files.pythonhosted.org/packages/30/e7/3f1fafa87e2643257474f9c4eec609f2193a61d907dce7dd4f3f2390ebd5/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293", size = 262931, upload-time = "2026-08-12T14:34:07.511Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/df/ebeb224a949d91829e5e114c6b64372a3c792b00762a9e951ce416f3a32d/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85", size = 251388, upload-time = "2026-08-12T14:34:08.949Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/64/9a6ce2e7acc5cf1b4636f78f82e89ff581e06a0216a40678b28bd4d832c4/charset_normalizer-3.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1", size = 251821, upload-time = "2026-08-12T14:34:10.138Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b1/6e69b8056f615e5ccff6b91ca16db2d47922251f016821a300c115267fef/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78", size = 244507, upload-time = "2026-08-12T14:34:11.488Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/34/02c15d6a0aa6b934dcdc136b111da63ae857b9fd51cf5505b0736337c2eb/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b", size = 240951, upload-time = "2026-08-12T14:34:12.991Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/15/0fe893d3e1c7d111280bd6c4bd4c1e431487a1124a1bcbce78dfeda3a3a8/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f", size = 266162, upload-time = "2026-08-12T14:34:14.232Z" },
+ { url = "https://files.pythonhosted.org/packages/55/ea/eca03527307670f5d102c295671a800c404ca958cf94fefd10fc963a72f0/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80", size = 251835, upload-time = "2026-08-12T14:34:15.48Z" },
+ { url = "https://files.pythonhosted.org/packages/03/a8/fee5633081e595fe9e191df6f215106c791ad596eddf5e41e39b8ea0f2e2/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9", size = 264314, upload-time = "2026-08-12T14:34:16.679Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/fb/17f47ae6ca35b562fb6e6f4b05f7aec6034217353eb4a23aaa3566dc7340/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4", size = 253194, upload-time = "2026-08-12T14:34:17.975Z" },
+ { url = "https://files.pythonhosted.org/packages/59/88/f2b0f7ebb92493e925889ff29239b3b0073ffafd91230dbfc69e5cf9389c/charset_normalizer-3.5.0-cp315-cp315-win32.whl", hash = "sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731", size = 179800, upload-time = "2026-08-12T14:34:19.409Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/f0/afb5bfdea52fd943b1960403847a276b8e900c6e4cd6a38752321b4eda64/charset_normalizer-3.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040", size = 203726, upload-time = "2026-08-12T14:34:20.656Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/71/219783eb691aa2ec879c0e521afdfe2b826f9678eed51b9c039d03e0db2b/charset_normalizer-3.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e", size = 183428, upload-time = "2026-08-12T14:34:21.975Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/d0/14aef3b9f80f2593c039d897e89034635b9eb0eb44b6ce5173bbd79ff338/charset_normalizer-3.5.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a", size = 368728, upload-time = "2026-08-12T14:34:23.221Z" },
+ { url = "https://files.pythonhosted.org/packages/10/fc/b249466ddbbeffa448b6597631e9091d1f01b5132ff8e7a0e21a6eb72b63/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698", size = 240925, upload-time = "2026-08-12T14:34:24.504Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/b9/c17e72aaa1b3e1ca6c184e8025cf138ed492d01a54f85286ff7d31253a4b/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075", size = 234932, upload-time = "2026-08-12T14:34:25.822Z" },
+ { url = "https://files.pythonhosted.org/packages/18/d7/f84ef0966bbe216f71029e34e7fa425a16b1682e2a40265e679dedf2b655/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74", size = 261733, upload-time = "2026-08-12T14:34:27.112Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/73/3e887fa0781a395339355ed934ab6561ceb5bb52574160f070224039c630/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4", size = 258460, upload-time = "2026-08-12T14:34:28.431Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/81/52ebd9849bf9e35d0b21fff115cb6543162a8e1f2f564e8f87121a336b8c/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258", size = 249894, upload-time = "2026-08-12T14:34:29.698Z" },
+ { url = "https://files.pythonhosted.org/packages/85/f3/9366492b8a5fe0187de282e001d61345740cf79eb4a5f20181d769be02b5/charset_normalizer-3.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4", size = 249540, upload-time = "2026-08-12T14:34:30.96Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/af/28bb5e5dbd3e67cb9196a62781ac2b6d79492f4fc7a069b6ca7d6d6c8d58/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2", size = 242734, upload-time = "2026-08-12T14:34:32.481Z" },
+ { url = "https://files.pythonhosted.org/packages/26/d6/7ccfa62b53b40fc06b2d3504825aa400764740bd10cf248fdc4272441b93/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d", size = 239580, upload-time = "2026-08-12T14:34:33.916Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/82/71c0c9b046697b8da66b3acefa8d5f92d00a9ef433ad7c3522b971d0369a/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e", size = 263281, upload-time = "2026-08-12T14:34:35.333Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/01/d027583c869f40ba980c1c76994adbd522c360a6327e72beb44d7c267385/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394", size = 250027, upload-time = "2026-08-12T14:34:36.991Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/e9/6475d739e0ec8bb1236e06263dc3affaffdf947d8114ad27024932f325da/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0", size = 257547, upload-time = "2026-08-12T14:34:38.277Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/60/d1f502fcaa048a2aca3ab80bfef8407659c131e4f1792fa805fec14b4960/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97", size = 251718, upload-time = "2026-08-12T14:34:39.562Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/69/76343dcf4381a698807ff8a20d89f66bbdd9f6222b0b17740f77ab764335/charset_normalizer-3.5.0-cp315-cp315t-win32.whl", hash = "sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7", size = 190757, upload-time = "2026-08-12T14:34:41.053Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/ea/d18147626a1667cc773c42104ab155a4ca5d6d4d174b7a35e01062213ea5/charset_normalizer-3.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775", size = 215431, upload-time = "2026-08-12T14:34:42.501Z" },
+ { url = "https://files.pythonhosted.org/packages/47/21/4869598aae0872d94faa5933918a4fe37ab2c5af9d095786e241f9506fed/charset_normalizer-3.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03", size = 193205, upload-time = "2026-08-12T14:34:43.781Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/f3/7b523d807cb5e73562ef8acf21d39cdb9d704955327362c781bc3478a73d/charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5", size = 330840, upload-time = "2026-08-12T14:34:45.06Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/de/fc68978fe78ca97063c96d764e41ff92ca639948f319271e0ff450e577a2/charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3", size = 251862, upload-time = "2026-08-12T14:34:46.58Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/cb/82b41a0ab7fb1a88065f1d78ad32696ad88ea3fe8e25b8189d08833938de/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3", size = 239484, upload-time = "2026-08-12T14:34:47.869Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/0c/19608b631f4538f908098d4a2d56a8f79a665e27cc58e9d90479761a9227/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438", size = 230602, upload-time = "2026-08-12T14:34:49.265Z" },
+ { url = "https://files.pythonhosted.org/packages/29/db/f648eb30e14eba301aed61e11672156f137905c1bdbb530151abe8065943/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a", size = 259208, upload-time = "2026-08-12T14:34:50.632Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/e0/ed2c8bdbac484d69614d6993143aeb6cb0f4dd1561c883402517b623c8ef/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539", size = 253659, upload-time = "2026-08-12T14:34:52.11Z" },
+ { url = "https://files.pythonhosted.org/packages/32/08/b4907cb9ec5b521d9d024ced13611240b86ef065c2eb15b3ad2334dc9940/charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706", size = 248821, upload-time = "2026-08-12T14:34:53.399Z" },
+ { url = "https://files.pythonhosted.org/packages/12/b2/e2d1abcfbc05822f0030869efb4e9f8a3658e13b4821796d4b62da917327/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26", size = 240271, upload-time = "2026-08-12T14:34:55.09Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/f9/4ba127ad610542fa3eabfa41c45bf12d357860a815b3566374ec0188e213/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3", size = 232155, upload-time = "2026-08-12T14:34:56.543Z" },
+ { url = "https://files.pythonhosted.org/packages/01/68/40613182366d00bd6dbd5f6c84a926cbd120960e038a8269e9ae7d782762/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e", size = 259674, upload-time = "2026-08-12T14:34:57.815Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/53/4574a14fa4c9de4a6c9f31725354bfa40b67f653e6d594ce1654f9a41b32/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49", size = 246122, upload-time = "2026-08-12T14:34:59.337Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/02/bd8030d13d92c058ca7b2b9615bbb3169569e144db64d65c149cd45abf5e/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45", size = 255221, upload-time = "2026-08-12T14:35:00.71Z" },
+ { url = "https://files.pythonhosted.org/packages/77/9d/10ecd3bcbe2666b3d4d4026c97b48f73990682815db516052a1e8f4a31c5/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8", size = 253450, upload-time = "2026-08-12T14:35:02.217Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/0f/d044c4872c0938a84f87b5027a698c0e61bacfc5c3551a4e749ca9b7bc5c/charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516", size = 173594, upload-time = "2026-08-12T14:35:03.845Z" },
+ { url = "https://files.pythonhosted.org/packages/10/6b/6046773901f1944b9a89436351529811ee958afc7b774563be9d74a6f0c3/charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74", size = 198959, upload-time = "2026-08-12T14:35:05.187Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/a6/b57708ac92aefc8e8389d51d5178129b81f03196da61ee2c23e687b8178a/charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca", size = 267055, upload-time = "2026-08-12T14:35:06.533Z" },
+ { url = "https://files.pythonhosted.org/packages/22/c7/754d09943a616937df61e4ba367c409ded2a987e872972098d51a6fcf73b/charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea", size = 67943, upload-time = "2026-08-12T14:35:30.363Z" },
]
[[package]]
@@ -1092,59 +1159,59 @@ wheels = [
[[package]]
name = "greenlet"
-version = "3.5.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" },
- { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" },
- { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" },
- { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" },
- { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" },
- { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" },
- { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" },
- { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" },
- { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" },
- { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" },
- { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" },
- { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" },
- { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" },
- { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" },
- { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" },
- { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" },
- { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" },
- { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" },
- { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" },
- { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" },
- { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" },
- { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" },
- { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" },
- { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" },
- { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" },
- { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" },
- { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" },
- { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" },
- { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" },
- { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" },
- { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" },
- { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" },
- { url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" },
- { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" },
- { url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" },
- { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" },
- { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" },
- { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" },
- { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" },
- { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" },
- { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" },
- { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" },
- { url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" },
- { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" },
- { url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" },
- { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" },
- { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" },
- { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" },
- { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" },
+version = "3.5.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" },
+ { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" },
+ { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" },
+ { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" },
+ { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" },
+ { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" },
+ { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" },
+ { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" },
+ { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" },
+ { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" },
+ { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" },
+ { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" },
+ { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" },
+ { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" },
+ { url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" },
+ { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" },
+ { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" },
+ { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" },
+ { url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" },
+ { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" },
+ { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" },
+ { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" },
]
[[package]]
@@ -1542,42 +1609,64 @@ wheels = [
[[package]]
name = "numpy"
-version = "2.5.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" },
- { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" },
- { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" },
- { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" },
- { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" },
- { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" },
- { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" },
- { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" },
- { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" },
- { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" },
- { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" },
- { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" },
- { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" },
- { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" },
- { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" },
- { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" },
- { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" },
- { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" },
- { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" },
- { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" },
- { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" },
- { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" },
- { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" },
- { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" },
- { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" },
- { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" },
- { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" },
- { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" },
- { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" },
- { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" },
- { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" },
- { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
+version = "2.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" },
+ { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" },
+ { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" },
+ { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" },
+ { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" },
+ { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" },
+ { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" },
+ { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" },
+ { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" },
+ { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" },
+ { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" },
+ { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" },
+ { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" },
+ { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" },
+ { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" },
+ { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" },
+ { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" },
+ { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" },
+ { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" },
+ { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" },
+ { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" },
+ { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" },
]
[[package]]
@@ -1707,7 +1796,7 @@ requires-dist = [
{ name = "aiohttp", specifier = "==3.14.3" },
{ name = "aiosignal", specifier = "==1.4.0" },
{ name = "aiosqlite", specifier = "==0.22.1" },
- { name = "alembic", specifier = "==1.19.0" },
+ { name = "alembic", specifier = "==1.19.1" },
{ name = "annotated-types", specifier = "==0.8.0" },
{ name = "anyio", specifier = "==4.14.2" },
{ name = "apitally", extras = ["fastapi"], specifier = "==0.25.1" },
@@ -1720,7 +1809,7 @@ requires-dist = [
{ name = "cachetools", specifier = "==7.1.7" },
{ name = "certifi", specifier = "==2026.7.22" },
{ name = "cffi", specifier = "==2.1.1" },
- { name = "charset-normalizer", specifier = "==3.4.9" },
+ { name = "charset-normalizer", specifier = "==3.5.0" },
{ name = "click", specifier = "==8.4.2" },
{ name = "cloud-sql-python-connector", specifier = "==1.21.0" },
{ name = "cryptography", specifier = "==50.0.0" },
@@ -1738,7 +1827,7 @@ requires-dist = [
{ name = "google-crc32c", specifier = "==1.8.0" },
{ name = "google-resumable-media", specifier = "==2.10.1" },
{ name = "googleapis-common-protos", specifier = "==1.75.1" },
- { name = "greenlet", specifier = "==3.5.4" },
+ { name = "greenlet", specifier = "==3.5.5" },
{ name = "gunicorn", specifier = "==23.0.0" },
{ name = "h11", specifier = "==0.16.0" },
{ name = "httpcore", specifier = "==1.0.9" },
@@ -1749,12 +1838,12 @@ requires-dist = [
{ name = "mako", specifier = "==1.4.1" },
{ name = "markupsafe", specifier = "==3.0.3" },
{ name = "multidict", specifier = "==6.7.1" },
- { name = "numpy", specifier = "==2.5.1" },
+ { name = "numpy", specifier = "==2.5.2" },
{ name = "packaging", specifier = "==26.3" },
{ name = "pandas", specifier = "==2.3.2" },
{ name = "pandas-stubs", specifier = "~=2.3.2" },
{ name = "pg8000", specifier = "==1.31.5" },
- { name = "phonenumbers", specifier = "==9.0.36" },
+ { name = "phonenumbers", specifier = "==9.0.37" },
{ name = "pillow", specifier = "==12.3.0" },
{ name = "pluggy", specifier = "==1.6.0" },
{ name = "propcache", specifier = "==0.5.2" },
@@ -1778,24 +1867,24 @@ requires-dist = [
{ name = "pytz", specifier = "==2026.3.post1" },
{ name = "requests", specifier = "==2.34.2" },
{ name = "rsa", specifier = "==4.9.1" },
- { name = "scramp", specifier = "==1.4.16" },
- { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.66.1" },
+ { name = "scramp", specifier = "==1.4.17" },
+ { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.68.0" },
{ name = "shapely", specifier = "==2.1.2" },
{ name = "six", specifier = "==1.17.0" },
{ name = "sniffio", specifier = "==1.3.1" },
- { name = "sqlalchemy", specifier = "==2.0.51" },
+ { name = "sqlalchemy", specifier = "==2.0.52" },
{ name = "sqlalchemy-continuum", specifier = "==1.7.0" },
{ name = "sqlalchemy-searchable", specifier = "==2.1.0" },
{ name = "sqlalchemy-utils", specifier = "==0.42.1" },
{ name = "sqlparse", specifier = ">=0.6.0" },
- { name = "starlette", specifier = "==1.4.1" },
+ { name = "starlette", specifier = "==1.6.0" },
{ name = "typer", specifier = "==0.27.1" },
{ name = "typing-extensions", specifier = "==4.16.0" },
- { name = "typing-inspection", specifier = "==0.4.2" },
+ { name = "typing-inspection", specifier = "==0.4.4" },
{ name = "tzdata", specifier = "==2026.3" },
{ name = "urllib3", specifier = "==2.7.0" },
{ name = "utm", specifier = "==0.9.0" },
- { name = "uvicorn", specifier = "==0.52.1" },
+ { name = "uvicorn", specifier = "==0.52.3" },
{ name = "yarl", specifier = "==1.24.5" },
]
@@ -1809,7 +1898,7 @@ dev = [
{ name = "black", specifier = ">=26.5.1" },
{ name = "faker", specifier = ">=25.0.0" },
{ name = "flake8", specifier = ">=7.3.0" },
- { name = "pre-commit", specifier = ">=4.6.1" },
+ { name = "pre-commit", specifier = ">=4.6.2" },
{ name = "pyhamcrest", specifier = ">=2.0.3" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-cov", specifier = ">=6.2.1" },
@@ -1964,11 +2053,11 @@ wheels = [
[[package]]
name = "phonenumbers"
-version = "9.0.36"
+version = "9.0.37"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0f/21/3f1561d3527b27121d6872aeecb6df2a8de8e11af9c36f5a0bd3483d2655/phonenumbers-9.0.36.tar.gz", hash = "sha256:60ca2a6870d2532d6e960105790e85e9d69270ede81746d10cf57f5e437b93ec", size = 2307271, upload-time = "2026-08-01T06:13:44.954Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4c/36/ee5b6d7ccc28cb7848e8dfefce94df9178b6318ed56adab97550b6ba99ec/phonenumbers-9.0.37.tar.gz", hash = "sha256:8923ab413c575b27da2815c9bef48290181aab565398b5e7e23cc7ace212a293", size = 2312364, upload-time = "2026-08-14T05:29:13.533Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/39/5d/74fbcb16b29927bf378f4ec4d47c7b48488b93e1f1af64b26544daf1d55f/phonenumbers-9.0.36-py2.py3-none-any.whl", hash = "sha256:c16a5b95178345ec2df1954578a4ac09e937443b2ee992dd7b15a8e8c81f4acf", size = 2595528, upload-time = "2026-08-01T06:13:41.591Z" },
+ { url = "https://files.pythonhosted.org/packages/99/68/544cb901964e8bfc3c8d215d5620e63a009b32437efaec905fdefdf59e26/phonenumbers-9.0.37-py2.py3-none-any.whl", hash = "sha256:55ae8243c1e2fe8ae2548d979e57155d9ae0f6084f8a1d6884050b143aff0a7c", size = 2595712, upload-time = "2026-08-14T05:29:10.464Z" },
]
[[package]]
@@ -2053,7 +2142,7 @@ wheels = [
[[package]]
name = "pre-commit"
-version = "4.6.1"
+version = "4.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
@@ -2062,9 +2151,9 @@ dependencies = [
{ name = "pyyaml" },
{ name = "virtualenv" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/25/3a/ddb78f32a0814e66b18a099377a106a2dcdce92d86a034d69d65df9b256e/pre_commit-4.6.1.tar.gz", hash = "sha256:03e809865c7d178b9979d06c761fcbfe6808fdaded8581a745bb110e52050421", size = 198646, upload-time = "2026-07-21T20:56:58.225Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl", hash = "sha256:0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717", size = 226186, upload-time = "2026-07-21T20:56:57.064Z" },
+ { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" },
]
[[package]]
@@ -2873,27 +2962,27 @@ wheels = [
[[package]]
name = "scramp"
-version = "1.4.16"
+version = "1.4.17"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asn1crypto" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/20/3f/dc633be307f4f9f5e41f9374da6bb2b4f0332fd388b1d92306909a308bcf/scramp-1.4.16.tar.gz", hash = "sha256:836bcecdc8843af76b35e11adbe818e3d7934d0d25aa48fd2e8cde362a2c6a9b", size = 21210, upload-time = "2026-08-06T16:48:56.725Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/68/76/6db02f36db58a7d009e90f51e961bcc3c44a1c930a744f026e30e791989b/scramp-1.4.17.tar.gz", hash = "sha256:28970f29ebc33df47f9975c805e5e5a360effe5b31045e607d64b3b60370dba1", size = 21291, upload-time = "2026-08-07T17:19:40.827Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cf/f0/5441d2bac230fa9a197c081672b57bdf2641e3988dddd39c687a3ad5267b/scramp-1.4.16-py3-none-any.whl", hash = "sha256:9e6148fca008ab6c6ce84658aeefd03d5d7f269d32fe08d07e134dae089e937a", size = 16083, upload-time = "2026-08-06T16:48:55.088Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/99/0e372781210cd36b2f2727e5be3ea93066edad7edd6fa2dfdec3b3e28845/scramp-1.4.17-py3-none-any.whl", hash = "sha256:a4e3fd2e8169461a28a13777a166d3da94274454f0714a7d3023fee124474ac8", size = 16131, upload-time = "2026-08-07T17:19:39.591Z" },
]
[[package]]
name = "sentry-sdk"
-version = "2.66.1"
+version = "2.68.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7f/6f/d59cad0889d15fde85254cf58e701484de3f3f0406003b3197746910b19b/sentry_sdk-2.66.1.tar.gz", hash = "sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc", size = 940543, upload-time = "2026-07-22T12:26:54.553Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/94/23b7dd072acb9628907bd3f4fbf61794a7b12a9db8f33c1276f70ae5ac92/sentry_sdk-2.68.0.tar.gz", hash = "sha256:648c58e9887311a03470a41539e24bdbbf64a30ca4f5336f7e3dcc87276400b3", size = 1008854, upload-time = "2026-08-13T09:06:21.268Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl", hash = "sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6", size = 505555, upload-time = "2026-07-22T12:26:52.71Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/9b/e2421d08956d0bc4691d995393d835e563886bff499d8fb10fdefae85a8d/sentry_sdk-2.68.0-py3-none-any.whl", hash = "sha256:538e56c2d03679d42f7c0cb5f1af73a7a510b00abc7e296c13ac49b107b713a4", size = 518670, upload-time = "2026-08-13T09:06:19.735Z" },
]
[package.optional-dependencies]
@@ -2973,36 +3062,30 @@ wheels = [
[[package]]
name = "sqlalchemy"
-version = "2.0.51"
+version = "2.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" },
- { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" },
- { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" },
- { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" },
- { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" },
- { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" },
- { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" },
- { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" },
- { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" },
- { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" },
- { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" },
- { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" },
- { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" },
- { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" },
- { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" },
- { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" },
- { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" },
- { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" },
- { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" },
- { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" },
- { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" },
- { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" },
+ { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" },
+ { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" },
+ { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" },
+ { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" },
+ { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" },
+ { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" },
+ { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" },
]
[[package]]
@@ -3053,14 +3136,14 @@ wheels = [
[[package]]
name = "starlette"
-version = "1.4.1"
+version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/0f/3c/76d2fd1f1357ed0f0108d8a5aa233dcf16e2946a8559c84912fe08e01ac7/starlette-1.4.1.tar.gz", hash = "sha256:b7332de6e9375593a29ba9eee1e6ecfeb3eb2043e2e19a13b4b71da73ff35540", size = 2709041, upload-time = "2026-08-05T15:17:26.23Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/75/0a/67e95f21498de41433babf7b1db0eeab449eb58872dfb831b27747a70fd0/starlette-1.4.1-py3-none-any.whl", hash = "sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19", size = 74019, upload-time = "2026-08-05T15:17:24.357Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
]
[[package]]
@@ -3107,14 +3190,14 @@ wheels = [
[[package]]
name = "typing-inspection"
-version = "0.4.2"
+version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
@@ -3167,15 +3250,15 @@ wheels = [
[[package]]
name = "uvicorn"
-version = "0.52.1"
+version = "0.52.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2e/28/64ca011edf31c715b4fad359c587ea52391aaffa125065695590241ff617/uvicorn-0.52.3.tar.gz", hash = "sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58", size = 100621, upload-time = "2026-08-13T16:50:02.899Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/2b/ebd108734a8204c6b4b93c681c9a38c5273b3ccd5d129fee4ffc1d97772c/uvicorn-0.52.3-py3-none-any.whl", hash = "sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c", size = 79859, upload-time = "2026-08-13T16:50:01.323Z" },
]
[[package]]
From 865b84d22357ebd630037b20c045b5e522af070d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 17 Aug 2026 22:09:04 +0000
Subject: [PATCH 055/151] build(deps): bump gunicorn from 23.0.0 to 26.0.0
Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 23.0.0 to 26.0.0.
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/23.0.0...26.0.0)
---
updated-dependencies:
- dependency-name: gunicorn
dependency-version: 26.0.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
---
pyproject.toml | 2 +-
requirements.txt | 6 +++---
uv.lock | 8 ++++----
3 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 07a73d554..267e99b56 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,7 +42,7 @@ dependencies = [
"google-resumable-media==2.10.1",
"googleapis-common-protos==1.75.1",
"greenlet==3.5.5",
- "gunicorn==23.0.0",
+ "gunicorn==26.0.0",
"h11==0.16.0",
"httpcore==1.0.9",
"httpx==0.28.1",
diff --git a/requirements.txt b/requirements.txt
index f628a6a45..8d2abfc84 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -912,9 +912,9 @@ greenlet==3.5.5 \
# via
# ocotilloapi
# sqlalchemy
-gunicorn==23.0.0 \
- --hash=sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d \
- --hash=sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec
+gunicorn==26.0.0 \
+ --hash=sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc \
+ --hash=sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf
# via ocotilloapi
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
diff --git a/uv.lock b/uv.lock
index e3dbf53cd..a615ad3ca 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1247,14 +1247,14 @@ wheels = [
[[package]]
name = "gunicorn"
-version = "23.0.0"
+version = "26.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" },
]
[[package]]
@@ -1828,7 +1828,7 @@ requires-dist = [
{ name = "google-resumable-media", specifier = "==2.10.1" },
{ name = "googleapis-common-protos", specifier = "==1.75.1" },
{ name = "greenlet", specifier = "==3.5.5" },
- { name = "gunicorn", specifier = "==23.0.0" },
+ { name = "gunicorn", specifier = "==26.0.0" },
{ name = "h11", specifier = "==0.16.0" },
{ name = "httpcore", specifier = "==1.0.9" },
{ name = "httpx", specifier = "==0.28.1" },
From 4c0099eb2f745312d5a43f5a9bb2ed25c32578b5 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Mon, 17 Aug 2026 17:11:14 -0700
Subject: [PATCH 056/151] feat(ogc): make /ogcapi-internal usable from ArcGIS
Pro and QGIS
The internal OGC mount accepted only `Authorization: Bearer `,
which neither desktop GIS client can supply in practice. ArcGIS Pro cannot
send a bearer token to an OGC API connection at all -- its dialog offers Basic
("Server Authentication"), Esri-portal OAuth, and custom request parameters,
and Esri does not support token-secured OGC service connections. QGIS can send
one, but shipped a regression (qgis/QGIS#60473) dropping the Authorization
header on OGC API - Features requests. Neither client can refresh an Authentik
access token before it expires.
Accept two further credential transports alongside the existing bearer JWT:
HTTP Basic (secret in either half of the credential) and a `?token=` query
parameter. Both carry a static API key, stored as SHA-256 digests only in
`INTERNAL_OGC_API_KEYS` as `label:sha256hex` entries and compared with
hmac.compare_digest. A key is a pre-authorized stand-in for INTERNAL_OGC_GROUP;
a bearer JWT is still checked for that group and still 403s without it.
The `?token=` value is stripped from the query string before the request
reaches pygeoapi, which would otherwise echo it into the `self` and `next`
links of every response body. It remains in the App Engine request log, so the
docs steer operators to Basic where the client supports it.
Also fixes a blocker independent of auth: PYGEOAPI_INTERNAL_SERVER_URL is set
in no deploy config, so `_internal_server_url()` fell through to its hardcoded
`http://localhost:8000` default in every deployed environment and pygeoapi
stamped that into every collection and items link. Both clients follow those
links to page, so the first page loaded and page two walked off to localhost.
Derive the URL from PYGEOAPI_SERVER_URL's application root instead, which every
deploy already sets, keeping the explicit override for a split-host setup.
Deployment sources the digest list from the Google Secret Manager secret
`internal-ogc-api-keys`, matching how the Jira and Slack credentials are
handled, rather than from a GitHub secret. The secret must exist in each
project before the next deploy or get-secretmanager-secrets fails the job; see
docs/internal-ogc-desktop-gis.md for the inert placeholder value. Revoking a
key requires a redeploy.
Smaller fixes in the same middleware: send WWW-Authenticate on 401, without
which neither client prompts for credentials, and match the mount path on a
segment boundary rather than a bare startswith.
Co-Authored-By: Claude Opus 5
---
.env.example | 13 ++
.github/app.template.yaml | 7 +
.github/workflows/CD_production.yml | 23 +--
.github/workflows/CD_staging.yml | 23 +--
.github/workflows/CD_testing.yml | 23 +--
CLAUDE.md | 12 ++
core/internal_ogc_auth.py | 192 +++++++++++++++++++++----
core/pygeoapi.py | 11 +-
docs/internal-ogc-desktop-gis.md | 139 ++++++++++++++++++
tests/test_internal_ogc_auth.py | 209 ++++++++++++++++++++++++++++
10 files changed, 600 insertions(+), 52 deletions(-)
create mode 100644 docs/internal-ogc-desktop-gis.md
create mode 100644 tests/test_internal_ogc_auth.py
diff --git a/.env.example b/.env.example
index 5fa1ad8ba..54c576b32 100644
--- a/.env.example
+++ b/.env.example
@@ -15,8 +15,21 @@ PYGEOAPI_POSTGRES_USER=your_username
# above; only the mount path, runtime dir, and advertised server URL differ.
PYGEOAPI_INTERNAL_MOUNT_PATH=/ogcapi-internal
PYGEOAPI_INTERNAL_RUNTIME_DIR=/tmp/pygeoapi-internal
+# Leave blank to derive from PYGEOAPI_SERVER_URL's application root. Only set
+# this when the internal mount is served from a different host than /ogcapi.
PYGEOAPI_INTERNAL_SERVER_URL=
+# Static API keys for /ogcapi-internal, for desktop GIS clients that cannot
+# refresh an Authentik access token (ArcGIS Pro, QGIS). Comma- or
+# whitespace-separated `label:sha256hex` entries; the label is bookkeeping
+# only. Blank means bearer-JWT access only. Mint one with:
+# python -c "import secrets,hashlib;k=secrets.token_urlsafe(32);print(k,hashlib.sha256(k.encode()).hexdigest())"
+# Give the first value to the user, put `label:` here.
+# Deployed environments source this from the Secret Manager secret
+# `internal-ogc-api-keys`, not from a GitHub secret.
+# See docs/internal-ogc-desktop-gis.md.
+INTERNAL_OGC_API_KEYS=
+
# Connection pool configuration for parallel transfers
# pool_size: number of persistent connections to maintain
# max_overflow: additional connections allowed during peak usage
diff --git a/.github/app.template.yaml b/.github/app.template.yaml
index bb44e584c..a0f67c7b9 100644
--- a/.github/app.template.yaml
+++ b/.github/app.template.yaml
@@ -34,6 +34,13 @@ env_variables:
PYGEOAPI_POSTGRES_PASSWORD: |-
${PYGEOAPI_POSTGRES_PASSWORD}
PYGEOAPI_SERVER_URL: "${PYGEOAPI_SERVER_URL}"
+ # Hashed static API keys for the authenticated /ogcapi-internal mount, as
+ # `label:sha256hex` entries, sourced from the Secret Manager secret
+ # `internal-ogc-api-keys`. Needed because ArcGIS Pro and QGIS cannot refresh
+ # an Authentik access token; see core/internal_ogc_auth.py and
+ # docs/internal-ogc-desktop-gis.md. Unset means bearer-JWT access only.
+ INTERNAL_OGC_API_KEYS: |-
+ ${INTERNAL_OGC_API_KEYS}
CLOUD_SQL_IAM_AUTH: "${CLOUD_SQL_IAM_AUTH}"
GCS_SERVICE_ACCOUNT_KEY: |-
${GCS_SERVICE_ACCOUNT_KEY}
diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml
index 39c5ff6b4..2df5ed439 100644
--- a/.github/workflows/CD_production.yml
+++ b/.github/workflows/CD_production.yml
@@ -71,11 +71,14 @@ jobs:
with:
credentials_json: ${{ secrets.CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY }}
- # Feedback endpoint credentials live in Google Secret Manager, not
- # GitHub secrets. The deploy service account needs
- # roles/secretmanager.secretAccessor on these secrets.
- - name: Fetch feedback secrets from Secret Manager
- id: feedback-secrets
+ # Application credentials live in Google Secret Manager, not GitHub
+ # secrets. The deploy service account needs
+ # roles/secretmanager.secretAccessor on these secrets. Every secret
+ # listed here must already exist in the target project or the deploy
+ # fails -- see docs/internal-ogc-desktop-gis.md for the placeholder
+ # value to seed internal-ogc-api-keys with.
+ - name: Fetch application secrets from Secret Manager
+ id: app-secrets
uses: 'google-github-actions/get-secretmanager-secrets@v3'
with:
secrets: |-
@@ -83,6 +86,7 @@ jobs:
jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token
slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url
slack_edits_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-edits-webhook-url
+ internal_ogc_api_keys:${{ vars.GCP_PROJECT_ID }}/internal-ogc-api-keys
- name: Run Alembic migrations on production database
env:
@@ -119,6 +123,7 @@ jobs:
PYGEOAPI_POSTGRES_PORT: "${{ vars.PYGEOAPI_POSTGRES_PORT || '5432' }}"
PYGEOAPI_POSTGRES_PASSWORD: "${{ secrets.PYGEOAPI_POSTGRES_PASSWORD }}"
PYGEOAPI_SERVER_URL: "${{ vars.PYGEOAPI_SERVER_URL }}"
+ INTERNAL_OGC_API_KEYS: "${{ steps.app-secrets.outputs.internal_ogc_api_keys }}"
CLOUD_SQL_IAM_AUTH: "true"
GCS_SERVICE_ACCOUNT_KEY: "${{ secrets.GCS_SERVICE_ACCOUNT_KEY }}"
GCS_BUCKET_NAME: "${{ vars.GCS_BUCKET_NAME }}"
@@ -128,11 +133,11 @@ jobs:
AUTHENTIK_TOKEN_URL: "${{ vars.AUTHENTIK_TOKEN_URL }}"
APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}"
JIRA_BASE_URL: "${{ vars.JIRA_BASE_URL || 'https://nmbgmr.atlassian.net' }}"
- JIRA_EMAIL: "${{ steps.feedback-secrets.outputs.jira_email }}"
- JIRA_API_TOKEN: "${{ steps.feedback-secrets.outputs.jira_api_token }}"
+ JIRA_EMAIL: "${{ steps.app-secrets.outputs.jira_email }}"
+ JIRA_API_TOKEN: "${{ steps.app-secrets.outputs.jira_api_token }}"
JIRA_DEFAULT_PROJECT: "${{ vars.JIRA_DEFAULT_PROJECT || 'BDMS' }}"
- SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_feedback_webhook_url }}"
- SLACK_EDITS_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_edits_webhook_url }}"
+ SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.app-secrets.outputs.slack_feedback_webhook_url }}"
+ SLACK_EDITS_WEBHOOK_URL: "${{ steps.app-secrets.outputs.slack_edits_webhook_url }}"
OCOTILLO_UI_BASE_URL: "${{ vars.OCOTILLO_UI_BASE_URL || 'https://ocotillo.newmexicowaterdata.org' }}"
run: |
export MAX_INSTANCES="10"
diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml
index c1cbd243d..c5bf72b4a 100644
--- a/.github/workflows/CD_staging.yml
+++ b/.github/workflows/CD_staging.yml
@@ -36,11 +36,14 @@ jobs:
with:
credentials_json: ${{ secrets.CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY }}
- # Feedback endpoint credentials live in Google Secret Manager, not
- # GitHub secrets. The deploy service account needs
- # roles/secretmanager.secretAccessor on these secrets.
- - name: Fetch feedback secrets from Secret Manager
- id: feedback-secrets
+ # Application credentials live in Google Secret Manager, not GitHub
+ # secrets. The deploy service account needs
+ # roles/secretmanager.secretAccessor on these secrets. Every secret
+ # listed here must already exist in the target project or the deploy
+ # fails -- see docs/internal-ogc-desktop-gis.md for the placeholder
+ # value to seed internal-ogc-api-keys with.
+ - name: Fetch application secrets from Secret Manager
+ id: app-secrets
uses: 'google-github-actions/get-secretmanager-secrets@v3'
with:
secrets: |-
@@ -48,6 +51,7 @@ jobs:
jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token
slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url
slack_edits_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-edits-webhook-url
+ internal_ogc_api_keys:${{ vars.GCP_PROJECT_ID }}/internal-ogc-api-keys
- name: Run Alembic migrations on staging database
env:
@@ -79,6 +83,7 @@ jobs:
PYGEOAPI_POSTGRES_PORT: "${{ vars.PYGEOAPI_POSTGRES_PORT || '5432' }}"
PYGEOAPI_POSTGRES_PASSWORD: "${{ secrets.PYGEOAPI_POSTGRES_PASSWORD }}"
PYGEOAPI_SERVER_URL: "${{ vars.PYGEOAPI_SERVER_URL }}"
+ INTERNAL_OGC_API_KEYS: "${{ steps.app-secrets.outputs.internal_ogc_api_keys }}"
CLOUD_SQL_IAM_AUTH: "true"
GCS_SERVICE_ACCOUNT_KEY: "${{ secrets.GCS_SERVICE_ACCOUNT_KEY }}"
GCS_BUCKET_NAME: "${{ vars.GCS_BUCKET_NAME }}"
@@ -88,11 +93,11 @@ jobs:
AUTHENTIK_TOKEN_URL: "${{ vars.AUTHENTIK_TOKEN_URL }}"
APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}"
JIRA_BASE_URL: "${{ vars.JIRA_BASE_URL || 'https://nmbgmr.atlassian.net' }}"
- JIRA_EMAIL: "${{ steps.feedback-secrets.outputs.jira_email }}"
- JIRA_API_TOKEN: "${{ steps.feedback-secrets.outputs.jira_api_token }}"
+ JIRA_EMAIL: "${{ steps.app-secrets.outputs.jira_email }}"
+ JIRA_API_TOKEN: "${{ steps.app-secrets.outputs.jira_api_token }}"
JIRA_DEFAULT_PROJECT: "${{ vars.JIRA_DEFAULT_PROJECT || 'BDMS' }}"
- SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_feedback_webhook_url }}"
- SLACK_EDITS_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_edits_webhook_url }}"
+ SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.app-secrets.outputs.slack_feedback_webhook_url }}"
+ SLACK_EDITS_WEBHOOK_URL: "${{ steps.app-secrets.outputs.slack_edits_webhook_url }}"
OCOTILLO_UI_BASE_URL: "${{ vars.OCOTILLO_UI_BASE_URL || 'https://ocotillo-staging.newmexicowaterdata.org' }}"
run: |
export MAX_INSTANCES="10"
diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml
index 0ed101d15..2a9a9dd2e 100644
--- a/.github/workflows/CD_testing.yml
+++ b/.github/workflows/CD_testing.yml
@@ -36,11 +36,14 @@ jobs:
with:
credentials_json: ${{ secrets.CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY }}
- # Feedback endpoint credentials live in Google Secret Manager, not
- # GitHub secrets. The deploy service account needs
- # roles/secretmanager.secretAccessor on these secrets.
- - name: Fetch feedback secrets from Secret Manager
- id: feedback-secrets
+ # Application credentials live in Google Secret Manager, not GitHub
+ # secrets. The deploy service account needs
+ # roles/secretmanager.secretAccessor on these secrets. Every secret
+ # listed here must already exist in the target project or the deploy
+ # fails -- see docs/internal-ogc-desktop-gis.md for the placeholder
+ # value to seed internal-ogc-api-keys with.
+ - name: Fetch application secrets from Secret Manager
+ id: app-secrets
uses: 'google-github-actions/get-secretmanager-secrets@v3'
with:
secrets: |-
@@ -48,6 +51,7 @@ jobs:
jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token
slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url
slack_edits_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-edits-webhook-url
+ internal_ogc_api_keys:${{ vars.GCP_PROJECT_ID }}/internal-ogc-api-keys
- name: Run Alembic migrations on staging database
env:
@@ -79,6 +83,7 @@ jobs:
PYGEOAPI_POSTGRES_PORT: "${{ vars.PYGEOAPI_POSTGRES_PORT || '5432' }}"
PYGEOAPI_POSTGRES_PASSWORD: "${{ secrets.PYGEOAPI_POSTGRES_PASSWORD }}"
PYGEOAPI_SERVER_URL: "${{ vars.PYGEOAPI_SERVER_URL }}"
+ INTERNAL_OGC_API_KEYS: "${{ steps.app-secrets.outputs.internal_ogc_api_keys }}"
CLOUD_SQL_IAM_AUTH: "true"
GCS_SERVICE_ACCOUNT_KEY: "${{ secrets.GCS_SERVICE_ACCOUNT_KEY }}"
GCS_BUCKET_NAME: "${{ vars.GCS_BUCKET_NAME }}"
@@ -88,11 +93,11 @@ jobs:
AUTHENTIK_TOKEN_URL: "${{ vars.AUTHENTIK_TOKEN_URL }}"
APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}"
JIRA_BASE_URL: "${{ vars.JIRA_BASE_URL || 'https://nmbgmr.atlassian.net' }}"
- JIRA_EMAIL: "${{ steps.feedback-secrets.outputs.jira_email }}"
- JIRA_API_TOKEN: "${{ steps.feedback-secrets.outputs.jira_api_token }}"
+ JIRA_EMAIL: "${{ steps.app-secrets.outputs.jira_email }}"
+ JIRA_API_TOKEN: "${{ steps.app-secrets.outputs.jira_api_token }}"
JIRA_DEFAULT_PROJECT: "${{ vars.JIRA_DEFAULT_PROJECT || 'BDMS' }}"
- SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_feedback_webhook_url }}"
- SLACK_EDITS_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_edits_webhook_url }}"
+ SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.app-secrets.outputs.slack_feedback_webhook_url }}"
+ SLACK_EDITS_WEBHOOK_URL: "${{ steps.app-secrets.outputs.slack_edits_webhook_url }}"
OCOTILLO_UI_BASE_URL: "${{ vars.OCOTILLO_UI_BASE_URL || 'https://ocotillo-staging.newmexicowaterdata.org' }}"
run: |
export MAX_INSTANCES="10"
diff --git a/CLAUDE.md b/CLAUDE.md
index 88802a2a0..30549235f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -180,6 +180,18 @@ dependency and FastAPI treats it as a query parameter.
only — it grants no access and removes no dependency. Apply it only to routes
that genuinely have none.
+**`/ogcapi-internal` is gated outside `Depends()`.** It is a raw Starlette
+Mount, so `core/internal_ogc_auth.py` gates it at the ASGI layer instead. It
+accepts a bearer Authentik JWT carrying `OGCInternal`, **or** a static API key
+presented as a bearer token, as the Basic password, or as `?token=`. Only the
+key digests are stored, as `label:sha256hex` entries in `INTERNAL_OGC_API_KEYS`
+— sourced in deployed environments from the Secret Manager secret
+`internal-ogc-api-keys` at deploy time, so revoking a key needs a redeploy.
+Never a GitHub secret. The static keys exist because
+ArcGIS Pro cannot send a bearer token at all and neither desktop client can
+refresh an Authentik token. Read **`docs/internal-ogc-desktop-gis.md`** before
+changing the credential paths.
+
### Database Configuration
The application supports two database modes (configured via `DB_DRIVER` in `.env`):
diff --git a/core/internal_ogc_auth.py b/core/internal_ogc_auth.py
index 85f1ee3dd..3d3631982 100644
--- a/core/internal_ogc_auth.py
+++ b/core/internal_ogc_auth.py
@@ -26,42 +26,177 @@
Kept separate from core/permissions.py to avoid a circular import with
core/pygeoapi.py.
+
+Three credential transports are accepted, because the desktop GIS clients
+this mount exists for cannot all carry an Authentik bearer token:
+
+ * ``Authorization: Bearer `` -- QGIS's OAuth2 authentication method,
+ scripts, and anything that can talk to Authentik directly.
+ * ``Authorization: Basic `` -- the only scheme ArcGIS
+ Pro's "Add OGC API connection" dialog supports with saved credentials
+ (Authentication > Server Authentication). Esri does not support
+ token-secured OGC service connections at all.
+ * ``?token=`` -- ArcGIS Pro's "Custom request parameters", which the
+ client re-appends to every request it issues. Also a workaround for the
+ QGIS regression where OGC API - Features requests dropped the
+ Authorization header (qgis/QGIS#60473).
+
+The Basic and query-parameter transports carry a *static API key* (see
+`api_key_label`) rather than a JWT, since neither ArcGIS nor QGIS can refresh
+an Authentik access token before it expires. A bearer JWT is still accepted
+and still checked for INTERNAL_OGC_GROUP membership; an API key is a
+pre-authorized stand-in for that same group.
+
+The query-parameter transport puts the secret in the request URL, which App
+Engine's request log records. Prefer Basic where the client supports it, and
+treat keys handed out for ArcGIS as log-exposed when rotating.
"""
+import base64
+import binascii
+import hashlib
+import hmac
import json
+import os
+from urllib.parse import parse_qsl, urlencode
from starlette.types import ASGIApp, Receive, Scope, Send
from core import permissions
from core.settings import settings
+# Comma- or whitespace-separated `label:sha256hex` entries. The label is for
+# operator bookkeeping (who holds this key) and never appears in a response.
+API_KEYS_ENV = "INTERNAL_OGC_API_KEYS"
+
+# Query parameter carrying a credential. Stripped before the request reaches
+# pygeoapi so it cannot trip pygeoapi's unknown-parameter handling or leak
+# into a provider's filter parsing.
+TOKEN_QUERY_PARAM = "token"
+
+# Sent on 401 so ArcGIS Pro and QGIS surface a credential prompt instead of a
+# bare failure.
+WWW_AUTHENTICATE = 'Basic realm="Ocotillo Internal OGC API", charset="UTF-8"'
+
+
+def _configured_api_keys() -> dict[str, str]:
+ """Parse API_KEYS_ENV into {label: sha256hex}.
+
+ Read fresh on every call for the same reason
+ permissions.authentication_disabled() is: an import-time snapshot diverges
+ from a value changed after import, and the two checks disagreeing is how
+ the earlier auth bugs in this codebase presented.
+
+ Malformed entries are skipped rather than raising. A typo in one entry
+ must not take the whole mount down for every other key holder.
+ """
+ raw = os.environ.get(API_KEYS_ENV) or ""
+ keys: dict[str, str] = {}
+ for entry in raw.replace(",", " ").split():
+ label, sep, digest = entry.partition(":")
+ digest = digest.strip().lower()
+ if not sep or not label.strip() or len(digest) != 64:
+ continue
+ try:
+ int(digest, 16)
+ except ValueError:
+ continue
+ keys[label.strip()] = digest
+ return keys
+
+
+def api_key_label(secret: str) -> str | None:
+ """Return the configured label for `secret`, or None if it matches none.
+
+ Compared as SHA-256 hex with hmac.compare_digest so neither the stored
+ material nor the comparison timing reveals a valid key.
+ """
+ configured = _configured_api_keys()
+ if not configured:
+ return None
+ presented = hashlib.sha256(secret.encode("utf-8")).hexdigest()
+ for label, expected in configured.items():
+ if hmac.compare_digest(presented, expected):
+ return label
+ return None
+
-def _extract_bearer_token(scope: Scope) -> str | None:
+def _extract_credential(scope: Scope) -> str | None:
+ """Pull a credential out of the Authorization header or ?token=.
+
+ Header wins over query parameter, and within the header both Bearer and
+ Basic are accepted. For Basic, the password half carries the secret
+ (username ignored, conventionally "apikey"); a Basic credential with an
+ empty password falls back to the username so pasting a key into either
+ field of a connection dialog works.
+ """
headers = dict(scope.get("headers") or [])
authorization = headers.get(b"authorization")
- if not authorization:
+ if authorization:
+ scheme, _, param = authorization.decode("latin-1").partition(" ")
+ scheme = scheme.lower()
+ param = param.strip()
+ if scheme == "bearer" and param:
+ return param
+ if scheme == "basic" and param:
+ try:
+ decoded = base64.b64decode(param, validate=True).decode("utf-8")
+ except (binascii.Error, UnicodeDecodeError, ValueError):
+ return None
+ username, sep, password = decoded.partition(":")
+ if not sep:
+ return None
+ return password or username or None
return None
- scheme, _, param = authorization.decode("latin-1").partition(" ")
- if scheme.lower() != "bearer" or not param:
- return None
- return param
+
+ for key, value in parse_qsl(
+ (scope.get("query_string") or b"").decode("latin-1"), keep_blank_values=True
+ ):
+ if key == TOKEN_QUERY_PARAM and value:
+ return value
+ return None
-async def _send_json(send: Send, status_code: int, detail: str) -> None:
+def _strip_token_query_param(scope: Scope) -> Scope:
+ """Return `scope` with any ?token= removed, copied only if it was present.
+
+ pygeoapi echoes the incoming query string into the `self` and `next` links
+ it emits; leaving the secret in place would publish it in every response
+ body as well as in the request log.
+ """
+ query_string = scope.get("query_string") or b""
+ if TOKEN_QUERY_PARAM.encode("latin-1") not in query_string:
+ return scope
+ pairs = parse_qsl(query_string.decode("latin-1"), keep_blank_values=True)
+ remaining = [(k, v) for k, v in pairs if k != TOKEN_QUERY_PARAM]
+ if len(remaining) == len(pairs):
+ return scope
+ scope = dict(scope)
+ scope["query_string"] = urlencode(remaining).encode("latin-1")
+ return scope
+
+
+async def _send_json(
+ send: Send, status_code: int, detail: str, *, challenge: bool = False
+) -> None:
body = json.dumps({"detail": detail}).encode("utf-8")
+ headers = [(b"content-type", b"application/json")]
+ if challenge:
+ headers.append((b"www-authenticate", WWW_AUTHENTICATE.encode("latin-1")))
await send(
{
"type": "http.response.start",
"status": status_code,
- "headers": [(b"content-type", b"application/json")],
+ "headers": headers,
}
)
await send({"type": "http.response.body", "body": body})
class InternalOGCAuthMiddleware:
- """Gates every request under `mount_path` behind INTERNAL_OGC_GROUP
- membership; requests to any other path pass straight through untouched.
+ """Gates every request under `mount_path` behind an API key or
+ INTERNAL_OGC_GROUP membership; requests to any other path pass straight
+ through untouched.
Registered via app.add_middleware(), which wraps the whole app -- the
path check below is what keeps this scoped to the internal mount only.
@@ -71,8 +206,13 @@ def __init__(self, app: ASGIApp, mount_path: str) -> None:
self.app = app
self.mount_path = mount_path
+ def _covers(self, path: str) -> bool:
+ # Segment-boundary match, not a bare startswith: with mount_path
+ # "/ogcapi" a plain prefix test would also swallow "/ogcapi-internal".
+ return path == self.mount_path or path.startswith(f"{self.mount_path}/")
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
- if scope["type"] != "http" or not scope["path"].startswith(self.mount_path):
+ if scope["type"] != "http" or not self._covers(scope["path"]):
await self.app(scope, receive, send)
return
@@ -87,25 +227,29 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
send, 424, permissions.bypass_misconfiguration_detail()
)
return
- await self.app(scope, receive, send)
+ await self.app(_strip_token_query_param(scope), receive, send)
return
- token = _extract_bearer_token(scope)
- if not token:
- await _send_json(send, 401, "Unauthorized")
+ secret = _extract_credential(scope)
+ if not secret:
+ await _send_json(send, 401, "Unauthorized", challenge=True)
return
- try:
- payload = permissions.decode_token_payload(token)
- except permissions.TokenInvalid:
- await _send_json(send, 401, "Could not validate credentials")
- return
+ if api_key_label(secret) is None:
+ # Not a static key, so it has to be an Authentik access token.
+ try:
+ payload = permissions.decode_token_payload(secret)
+ except permissions.TokenInvalid:
+ await _send_json(
+ send, 401, "Could not validate credentials", challenge=True
+ )
+ return
- if permissions.INTERNAL_OGC_GROUP not in payload.get("groups", []):
- await _send_json(send, 403, "Forbidden")
- return
+ if permissions.INTERNAL_OGC_GROUP not in payload.get("groups", []):
+ await _send_json(send, 403, "Forbidden")
+ return
- await self.app(scope, receive, send)
+ await self.app(_strip_token_query_param(scope), receive, send)
# ============= EOF =============================================
diff --git a/core/pygeoapi.py b/core/pygeoapi.py
index 377d77c0f..017af4588 100644
--- a/core/pygeoapi.py
+++ b/core/pygeoapi.py
@@ -196,7 +196,16 @@ def _internal_server_url() -> str:
configured = os.environ.get("PYGEOAPI_INTERNAL_SERVER_URL")
if configured:
return configured.rstrip("/")
- return f"http://localhost:8000{_internal_mount_path()}"
+ # Derived from the application root rather than hardcoded to localhost.
+ # PYGEOAPI_INTERNAL_SERVER_URL is set in no deploy config -- not
+ # app.template.yaml, not any of the three CD workflows -- so every
+ # deployed environment fell into this branch and pygeoapi stamped
+ # "http://localhost:8000/ogcapi-internal" into the `self` and `next` links
+ # of every collection and items response. QGIS and ArcGIS Pro follow those
+ # links to page, so both walked off to localhost after the first page.
+ # _app_base_url() reads PYGEOAPI_SERVER_URL, which every deploy already
+ # sets, and still resolves to http://localhost:8000 for local development.
+ return f"{_app_base_url()}{_internal_mount_path()}"
def _app_base_url() -> str:
diff --git a/docs/internal-ogc-desktop-gis.md b/docs/internal-ogc-desktop-gis.md
new file mode 100644
index 000000000..7efa66403
--- /dev/null
+++ b/docs/internal-ogc-desktop-gis.md
@@ -0,0 +1,139 @@
+# Connecting ArcGIS Pro and QGIS to `/ogcapi-internal`
+
+The internal OGC API mount serves the unfiltered (private- and draft-inclusive)
+collections. It is gated by `core/internal_ogc_auth.py`, an ASGI middleware that
+runs in front of the raw Starlette Mount — FastAPI's `Depends()` machinery never
+sees these requests, so none of the `*_dependency` role parameters apply here.
+
+## Why there are static API keys at all
+
+The mount originally accepted only `Authorization: Bearer `.
+Neither desktop client can sustain that:
+
+- **ArcGIS Pro** cannot send a bearer token to an OGC API connection. Its
+ connection dialog offers Basic ("Server Authentication"), Esri-portal OAuth,
+ and "Custom request parameters" (appended to the request URL). Esri
+ [does not support token-secured OGC service connections](https://pro.arcgis.com/en/pro-app/latest/help/data/services/add-ogc-api-services.htm).
+- **QGIS** can send one via its OAuth2 or API Header authentication methods, but
+ shipped a regression where OGC API - Features requests dropped the
+ Authorization header entirely ([qgis/QGIS#60473](https://github.com/qgis/QGIS/issues/60473)).
+
+Neither client can refresh an Authentik access token before it expires, so even
+a working bearer flow means re-pasting a token every hour. A static key issued
+per user solves both problems.
+
+## Accepted credentials
+
+| Transport | Carries | Used by |
+| --- | --- | --- |
+| `Authorization: Bearer ` | Authentik JWT **or** API key | QGIS OAuth2 / API Header, scripts |
+| `Authorization: Basic ` | API key (or JWT) as the password | ArcGIS Pro, QGIS Basic |
+| `?token=` | API key (or JWT) | ArcGIS Pro custom request parameters |
+
+A JWT must additionally carry the `OGCInternal` group (`INTERNAL_OGC_GROUP` in
+`core/permissions.py`); a valid JWT without it gets 403. An API key is a
+pre-authorized stand-in for that group and carries no per-user claims.
+
+The `?token=` value is stripped from the query string before the request reaches
+pygeoapi, so it never lands in the `self`/`next` links pygeoapi echoes into
+response bodies. It is still recorded in App Engine's request log — prefer Basic
+where the client supports it.
+
+## Where the keys live
+
+Only the **SHA-256 digests** are stored, never the keys themselves. The digest
+list lives in a Google Secret Manager secret named `internal-ogc-api-keys`, one
+per GCP project (production, staging, testing) — the same place the Jira and
+Slack credentials live, not a GitHub secret.
+
+CD reads it at deploy time (`Fetch application secrets from Secret Manager` in
+each `.github/workflows/CD_*.yml`) and `envsubst` renders it into `app.yaml` as
+the `INTERNAL_OGC_API_KEYS` environment variable, which
+`core/internal_ogc_auth.py` parses. The app makes no Secret Manager call at
+runtime.
+
+Consequences worth knowing:
+
+- **The secret must exist before the next deploy of any environment.**
+ `get-secretmanager-secrets` fails the whole job on a missing secret. Seed each
+ project with a placeholder that parses to zero keys:
+
+ ```bash
+ printf 'placeholder:none' | gcloud secrets create internal-ogc-api-keys --data-file=- --project
+ ```
+
+ The parser skips any entry whose digest is not 64 hex characters, so that
+ value is inert and means "bearer-JWT access only".
+
+- **Revoking a key requires a redeploy.** Adding a secret version does not
+ affect a running instance. If revocation ever needs to be immediate, that is
+ the point to switch to a runtime fetch with a TTL cache (same shape as the
+ JWKS cache in `core/permissions.py`) or to a keys table in Postgres.
+
+- The deploy service account needs `roles/secretmanager.secretAccessor` on
+ `internal-ogc-api-keys` in each project, alongside the four it already has.
+
+## Issuing a key
+
+```bash
+python -c "import secrets,hashlib;k=secrets.token_urlsafe(32);print('key: ',k);print('digest:',hashlib.sha256(k.encode()).hexdigest())"
+```
+
+Give the **key** to the user over a secure channel and keep only the digest.
+Append `:` to the secret's value — comma- or whitespace-separated
+entries, where the label is bookkeeping only (the person's name or the machine):
+
+```bash
+gcloud secrets versions add internal-ogc-api-keys --data-file=- --project
+```
+
+Then redeploy that environment. For local development set
+`INTERNAL_OGC_API_KEYS` in `.env` directly; leaving it unset means bearer-JWT
+access only.
+
+## ArcGIS Pro
+
+Basic auth (preferred):
+
+1. **Insert** > **Connections** > **Server** > **New OGC API Server**.
+2. Server URL: `https:///ogcapi-internal`
+3. Authentication: **Server Authentication**. User: anything (`apikey`).
+ Password: the issued key. Check **Save Login** to persist it.
+
+If Basic is refused by an intermediary, use the query parameter instead: leave
+Authentication as **No Authentication** and add a custom request parameter with
+name `token` and value the issued key. Pro re-appends it to every request it
+issues, including paging.
+
+## QGIS
+
+1. **Layer** > **Data Source Manager** > **WFS / OGC API - Features** > **New**.
+2. URL: `https:///ogcapi-internal`
+3. Authentication tab > **Create a new authentication configuration**:
+ - **Basic authentication** — username `apikey`, password the issued key. Works
+ on all supported QGIS versions.
+ - Or **API Header** — header `Authorization`, value `Bearer `. Avoid on
+ 3.40.3, where OGC API - Features drops the header.
+4. **Connect**, then add the collections you need.
+
+Staff who prefer real Authentik identity can instead configure QGIS's **OAuth2**
+authentication method against the Authentik provider; the mount accepts those
+tokens unchanged, provided the account is in `OGCInternal`.
+
+## Advertised URLs
+
+pygeoapi stamps an absolute server URL into every `links` href, and both clients
+follow those links to page through `items`. `_internal_server_url()` in
+`core/pygeoapi.py` derives that from `PYGEOAPI_SERVER_URL`'s application root, so
+no additional deploy variable is required. Set
+`PYGEOAPI_INTERNAL_SERVER_URL` only if the internal mount is served from a
+different host than the public `/ogcapi` mount.
+
+## Troubleshooting
+
+| Symptom | Cause |
+| --- | --- |
+| 401 with `WWW-Authenticate: Basic` | No credential reached the server. In QGIS, confirm the auth config is selected on the *connection*, not just created. |
+| 403 | Valid Authentik token, but the account is not in the `OGCInternal` group. |
+| 424 | `AUTHENTIK_DISABLE_AUTHENTICATION=1` with `MODE` other than `development`. Misconfigured deploy. |
+| First page loads, paging fails against `localhost` | `PYGEOAPI_SERVER_URL` unset or wrong for the environment. |
diff --git a/tests/test_internal_ogc_auth.py b/tests/test_internal_ogc_auth.py
new file mode 100644
index 000000000..68abb3126
--- /dev/null
+++ b/tests/test_internal_ogc_auth.py
@@ -0,0 +1,209 @@
+# ===============================================================================
+# Copyright 2026
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Credential handling for the internal OGC mount's ASGI auth gate.
+
+The middleware runs outside FastAPI's Depends() machinery, so
+`override_authentication()` and app.dependency_overrides do not reach it.
+These tests drive a minimal Starlette app wrapped in the real middleware
+instead of the full application, which keeps them independent of the database
+and of whether pygeoapi's backing views exist.
+"""
+
+import base64
+import hashlib
+import secrets
+
+import pytest
+from starlette.applications import Starlette
+from starlette.responses import JSONResponse
+from starlette.routing import Route
+from starlette.testclient import TestClient
+
+from core import permissions
+from core.internal_ogc_auth import (
+ API_KEYS_ENV,
+ InternalOGCAuthMiddleware,
+ api_key_label,
+)
+
+MOUNT = "/ogcapi-internal"
+
+
+def _digest(secret: str) -> str:
+ return hashlib.sha256(secret.encode("utf-8")).hexdigest()
+
+
+async def _echo(request):
+ # Echoes the query string the downstream app actually received, so the
+ # ?token= stripping can be asserted from the client side.
+ return JSONResponse({"query": request.url.query})
+
+
+@pytest.fixture
+def gate(monkeypatch):
+ """Middleware under test with the dev bypass forced off.
+
+ The bypass is enabled in CI for the suite as a whole; leaving it on would
+ let every request through and assert nothing.
+ """
+ monkeypatch.setenv("AUTHENTIK_DISABLE_AUTHENTICATION", "0")
+ app = Starlette(
+ routes=[
+ Route(MOUNT, _echo),
+ Route(f"{MOUNT}/collections", _echo),
+ Route("/ogcapi/collections", _echo),
+ ]
+ )
+ app.add_middleware(InternalOGCAuthMiddleware, mount_path=MOUNT)
+ return TestClient(app)
+
+
+@pytest.fixture
+def api_key(monkeypatch):
+ key = secrets.token_urlsafe(32)
+ monkeypatch.setenv(API_KEYS_ENV, f"arcgis-desktop:{_digest(key)}")
+ return key
+
+
+def test_public_mount_is_untouched(gate):
+ # The gate wraps the whole app; only the internal mount may be affected.
+ assert gate.get("/ogcapi/collections").status_code == 200
+
+
+def test_missing_credential_is_challenged(gate):
+ response = gate.get(f"{MOUNT}/collections")
+
+ assert response.status_code == 401
+ # Without this header neither ArcGIS Pro nor QGIS prompts for credentials.
+ assert response.headers["www-authenticate"].startswith("Basic realm=")
+
+
+def test_basic_auth_with_api_key_is_accepted(gate, api_key):
+ credentials = base64.b64encode(f"apikey:{api_key}".encode()).decode()
+
+ response = gate.get(
+ f"{MOUNT}/collections", headers={"Authorization": f"Basic {credentials}"}
+ )
+
+ assert response.status_code == 200
+
+
+def test_basic_auth_accepts_the_key_in_the_username_field(gate, api_key):
+ # Connection dialogs that leave the password blank must still work.
+ credentials = base64.b64encode(f"{api_key}:".encode()).decode()
+
+ response = gate.get(
+ f"{MOUNT}/collections", headers={"Authorization": f"Basic {credentials}"}
+ )
+
+ assert response.status_code == 200
+
+
+def test_basic_auth_with_a_wrong_key_is_rejected(gate, api_key):
+ credentials = base64.b64encode(b"apikey:not-the-key").decode()
+
+ response = gate.get(
+ f"{MOUNT}/collections", headers={"Authorization": f"Basic {credentials}"}
+ )
+
+ assert response.status_code == 401
+
+
+def test_query_parameter_token_is_accepted_and_stripped(gate, api_key):
+ response = gate.get(f"{MOUNT}/collections?limit=5&token={api_key}")
+
+ assert response.status_code == 200
+ # pygeoapi echoes the incoming query string into its self/next links, so
+ # a token left in place would be published in every response body.
+ assert "token" not in response.json()["query"]
+ assert "limit=5" in response.json()["query"]
+
+
+def test_query_parameter_token_is_rejected_when_wrong(gate, api_key):
+ assert gate.get(f"{MOUNT}/collections?token=nope").status_code == 401
+
+
+def test_bearer_api_key_is_accepted(gate, api_key):
+ response = gate.get(
+ f"{MOUNT}/collections", headers={"Authorization": f"Bearer {api_key}"}
+ )
+
+ assert response.status_code == 200
+
+
+def test_bearer_jwt_still_requires_the_internal_group(gate, monkeypatch):
+ monkeypatch.setattr(
+ permissions, "decode_token_payload", lambda token: {"groups": ["Viewer"]}
+ )
+
+ response = gate.get(
+ f"{MOUNT}/collections", headers={"Authorization": "Bearer a-jwt"}
+ )
+
+ assert response.status_code == 403
+
+
+def test_bearer_jwt_with_the_internal_group_is_accepted(gate, monkeypatch):
+ monkeypatch.setattr(
+ permissions,
+ "decode_token_payload",
+ lambda token: {"groups": [permissions.INTERNAL_OGC_GROUP]},
+ )
+
+ response = gate.get(
+ f"{MOUNT}/collections", headers={"Authorization": "Bearer a-jwt"}
+ )
+
+ assert response.status_code == 200
+
+
+def test_invalid_jwt_is_rejected(gate, monkeypatch):
+ def _raise(token):
+ raise permissions.TokenInvalid("bad signature")
+
+ monkeypatch.setattr(permissions, "decode_token_payload", _raise)
+
+ response = gate.get(
+ f"{MOUNT}/collections", headers={"Authorization": "Bearer a-jwt"}
+ )
+
+ assert response.status_code == 401
+
+
+def test_bypass_outside_development_fails_closed(gate, monkeypatch):
+ monkeypatch.setenv("AUTHENTIK_DISABLE_AUTHENTICATION", "1")
+ monkeypatch.setenv("MODE", "production")
+
+ response = gate.get(f"{MOUNT}/collections")
+
+ assert response.status_code == 424
+
+
+def test_no_configured_keys_means_no_key_is_valid(gate, monkeypatch):
+ monkeypatch.delenv(API_KEYS_ENV, raising=False)
+
+ assert api_key_label("anything") is None
+
+
+def test_malformed_key_entries_do_not_disable_the_valid_ones(monkeypatch):
+ good = secrets.token_urlsafe(32)
+ monkeypatch.setenv(
+ API_KEYS_ENV,
+ f"missing-colon, short:abc123, blank-digest:, good:{_digest(good)}",
+ )
+
+ assert api_key_label(good) == "good"
+ assert api_key_label("short") is None
From 6041b0f7a3e25d5a84af31d955cd23b3497da4db Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 08:19:16 -0700
Subject: [PATCH 057/151] ci: upload coverage to Codecov and add a review skill
The unit-tests job already generated coverage.xml, but the only Codecov
step ran with report_type: test_results, which uploads junit.xml alone --
one action run sends one report type. Coverage was measured and then
discarded. Add a second Codecov step for the coverage report.
Scope coverage while here. Bare `--cov` measures every imported module,
which pulled the whole virtualenv into the report. Keep the source root
single: listing each package separately makes coverage treat every entry
as its own root, so api/wells.py is reported as wells.py and collides
with same-named modules in db/, schemas/, and services/.
Both codecov.yml status checks are informational, so they comment on the
PR without blocking the merge until the baseline settles.
The review skill gives Copilot code review the conventions that fail
silently rather than failing a test -- `user=dep` instead of
`user: dep`, the orthogonal role families, the domain/ import ban.
Co-Authored-By: Claude Opus 5
---
.github/skills/code-review/SKILL.md | 84 +++++++++++++++++++++++++++++
.github/workflows/tests.yml | 13 ++++-
codecov.yml | 28 ++++++++++
pyproject.toml | 21 ++++++++
4 files changed, 145 insertions(+), 1 deletion(-)
create mode 100644 .github/skills/code-review/SKILL.md
create mode 100644 codecov.yml
diff --git a/.github/skills/code-review/SKILL.md b/.github/skills/code-review/SKILL.md
new file mode 100644
index 000000000..723969783
--- /dev/null
+++ b/.github/skills/code-review/SKILL.md
@@ -0,0 +1,84 @@
+---
+name: code-review
+description: Repository-specific review rules for OcotilloAPI pull requests. Use this when reviewing a pull request in this repository, so review comments account for the authorization, schema, domain-layer, and migration conventions that are easy to violate silently.
+---
+
+# Reviewing OcotilloAPI pull requests
+
+OcotilloAPI is a FastAPI + PostgreSQL/PostGIS geospatial service for the New Mexico
+Bureau of Geology and Mineral Resources. Read `CLAUDE.md` at the repository root for
+the full architecture; this skill lists the mistakes worth flagging in review because
+they fail silently rather than breaking a test.
+
+Use the GitHub MCP server tools (`list_workflow_runs`, `summarize_job_log_failures`,
+`get_job_logs`) to check whether the `Test Suite` workflow is failing before commenting
+on behavior — a failing `unit-tests` job often explains the diff better than the diff does.
+
+## Authorization is opt-in, so omissions are invisible
+
+Authorization is applied per endpoint via a parameter in the route signature, not by a
+router-level `dependencies=[...]`. Two failure modes to flag:
+
+1. A new route with no `user: _dependency` parameter is fully public and raises no
+ error. If the pull request adds a route, check whether it belongs in the anonymous-route
+ allowlist in `tests/test_authorization.py`. If it does not, it needs a role dependency.
+2. The dependency must be a **type annotation** (`user: viewer_dependency`), never a default
+ value (`user=viewer_dependency`). The latter silently disables the dependency, and FastAPI
+ reinterprets it as a query parameter. Flag this every time.
+
+Role families are orthogonal: general `Admin` confers nothing in the `AMP*` or `Lexicon*`
+families. Only tiers within one family nest. A diff that treats `Admin` as a superset of
+`AMPEditor` is wrong.
+
+`@in_public_schema` controls anonymous OpenAPI visibility only. It grants no access and
+removes no dependency; flag any use that appears to be standing in for authorization.
+
+`/ogcapi-internal` is a raw Starlette Mount and is gated at the ASGI layer in
+`core/internal_ogc_auth.py`, outside `Depends()`. Changes to its credential paths should
+cite `docs/internal-ogc-desktop-gis.md`.
+
+The development auth bypass (`AUTHENTIK_DISABLE_AUTHENTICATION=1`) is honored only when
+`MODE=development`. Any change that widens that condition is a security finding.
+
+## Model changes are a five-step workflow
+
+A pull request that edits a model in `db/` is incomplete unless it also covers the matching
+Pydantic schemas in `schemas/`, an Alembic migration, test fixtures and payloads in `tests/`,
+and the field mappings in `transfers/` when the field is populated from the legacy AMPAPI
+data. Flag whichever step is missing.
+
+Schema conventions: `Create` schemas use `` for non-nullable and ` | None = None`
+for nullable; `Update` schemas make every field optional with a `None` default; `Response`
+schemas use `` for non-nullable and ` | None` for nullable.
+
+Validation split: input validation belongs in Pydantic validators and produces 422s. Database
+constraint checks are manual in the endpoint and produce 409s. Custom exceptions should use
+`PydanticStyleException` from `services/exceptions_helper.py` so error bodies stay consistent.
+
+## Layer boundaries
+
+`domain/` holds business rules as plain functions over plain values. Modules there must not
+import from `api/`, `db/`, `schemas/`, or `services/`, and must not import `fastapi`,
+`sqlalchemy`, `pydantic`, or `httpx`. Flag any new import that breaks this — it is what keeps
+the rules testable without a database. Domain errors subclass `ValueError` because the CSV
+importers treat a `ValueError` on a row as a per-row validation failure; an exception type
+that does not subclass `ValueError` will escape that handling. See `ADR4.md`.
+
+`services/` is the layer that loads data, calls the domain rule, and persists the result.
+
+## Spatial and query specifics
+
+All geometries are WGS84 (SRID 4326). Legacy transfer scripts convert from UTM (SRID 26913);
+a missing transformation puts points in the wrong hemisphere rather than raising.
+
+List filters arrive from the Refine UI as repeated `filter` query parameters containing JSON.
+Association-backed columns are virtual and map to EXISTS subqueries in
+`services/query_helper.py`, not to `ILIKE` on an ORM proxy. Sorting by monitoring status or
+well status must use SQL subqueries on `StatusHistory`, because `ORDER BY` cannot see a Python
+`@property`. See `docs/refine-json-filters-and-virtual-fields.md`.
+
+## Migrations
+
+Alembic schema migrations run automatically in the deployment pipeline. Registered *data*
+migrations do not — they sit unapplied until someone runs them by hand. If a pull request adds
+a data migration, ask how and when it will be run.
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 9f8b0fcb0..05cd1cd42 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -93,9 +93,20 @@ jobs:
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d ocotilloapi_test -c "CREATE EXTENSION IF NOT EXISTS postgis"
- name: Run tests
- run: uv run pytest -vv --durations=20 --cov --cov-report=xml --junitxml=junit.xml --ignore=tests/transfers
+ run: uv run pytest -vv --durations=20 --cov --cov-report=xml --cov-report=term-missing --junitxml=junit.xml --ignore=tests/transfers
+
+ - name: Upload coverage to Codecov
+ # report_type defaults to "coverage"; the test-results upload below is a
+ # separate call because one action run sends one report type.
+ if: ${{ !cancelled() }}
+ uses: codecov/codecov-action@v6
+ with:
+ files: coverage.xml
+ fail_ci_if_error: true
+ token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload results to Codecov
+ if: ${{ !cancelled() }}
uses: codecov/codecov-action@v6
with:
report_type: test_results
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 000000000..30ae88f35
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,28 @@
+# Codecov configuration.
+#
+# Both status checks are `informational: true`, so they report coverage on the
+# PR without blocking the merge. Drop that line from a check once the baseline
+# is stable enough to enforce.
+coverage:
+ status:
+ project:
+ default:
+ informational: true
+ target: auto
+ threshold: 1%
+ patch:
+ default:
+ informational: true
+ target: 80%
+
+comment:
+ layout: "condensed_header, diff, flags, components"
+ require_changes: true
+
+ignore:
+ - "alembic/**"
+ - "docker/**"
+ - "features/**"
+ - "geoserver_iac/**"
+ - "scripts/**"
+ - "tests/**"
diff --git a/pyproject.toml b/pyproject.toml
index 832eb4532..e7d31376e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -108,6 +108,27 @@ package = true
[tool.setuptools]
packages = ["alembic", "cli", "core", "data_migrations", "db", "domain", "schemas", "services", "transfers"]
+# Bare `--cov` measures every imported module, which pulls the whole virtualenv
+# into the report. Scope it to first-party code instead. Keep this a single
+# source root -- listing each package separately makes coverage treat every
+# entry as its own root, so `api/wells.py` is reported as `wells.py` and
+# collides with same-named modules in db/, schemas/, and services/.
+[tool.coverage.run]
+source = ["."]
+relative_files = true
+omit = [
+ ".venv/*",
+ "alembic/*",
+ "docker/*",
+ "features/*",
+ "geoserver_iac/*",
+ "scripts/*",
+ "tests/*",
+]
+
+[tool.coverage.report]
+show_missing = true
+
[project.scripts]
oco = "cli.cli:cli"
From 299aa7d17c3f9541e0d6a401ad005f4f93479f51 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 08:34:08 -0700
Subject: [PATCH 058/151] ci: drop Codecov and report coverage from the
workflow itself
The project no longer has a Codecov account, so the uploads were feeding
a service nobody reads -- and the coverage step ran with
fail_ci_if_error: true, which would have turned every pull request red
the moment the token was revoked.
Report coverage in-workflow instead: a summary table in the job summary,
and coverage.xml, htmlcov/, and junit.xml kept as a build artifact. Both
steps run with `if: !cancelled()` so a coverage failure still produces
the report that explains it.
Gate the total with --cov-fail-under=55, against a measured baseline of
58.15% over 881 tests. The flag lives in the workflow rather than in
pyproject so that running a single test file locally is not failed by
the whole-project total.
Also drop the README badge, which embedded a Codecov token in its URL.
The CODECOV_TOKEN repository secret is now unused.
Co-Authored-By: Claude Opus 5
---
.github/workflows/tests.yml | 37 +++++++++++++++++++++++--------------
.gitignore | 1 +
README.md | 1 -
codecov.yml | 28 ----------------------------
4 files changed, 24 insertions(+), 43 deletions(-)
delete mode 100644 codecov.yml
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 05cd1cd42..66930cd64 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -93,24 +93,33 @@ jobs:
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d ocotilloapi_test -c "CREATE EXTENSION IF NOT EXISTS postgis"
- name: Run tests
- run: uv run pytest -vv --durations=20 --cov --cov-report=xml --cov-report=term-missing --junitxml=junit.xml --ignore=tests/transfers
+ # --cov-fail-under is set here rather than in pyproject so that running a
+ # single test file locally does not fail on the whole-project total.
+ run: uv run pytest -vv --durations=20 --cov --cov-report=xml --cov-report=html --cov-report=term-missing --cov-fail-under=55 --junitxml=junit.xml --ignore=tests/transfers
- - name: Upload coverage to Codecov
- # report_type defaults to "coverage"; the test-results upload below is a
- # separate call because one action run sends one report type.
+ - name: Write coverage summary
+ # Runs even when the coverage gate above fails, so the job summary shows
+ # which modules dropped rather than only the failing total.
if: ${{ !cancelled() }}
- uses: codecov/codecov-action@v6
- with:
- files: coverage.xml
- fail_ci_if_error: true
- token: ${{ secrets.CODECOV_TOKEN }}
-
- - name: Upload results to Codecov
+ run: |
+ {
+ echo "## Coverage"
+ echo
+ echo '```'
+ uv run coverage report
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload coverage reports
if: ${{ !cancelled() }}
- uses: codecov/codecov-action@v6
+ uses: actions/upload-artifact@v4
with:
- report_type: test_results
- token: ${{ secrets.CODECOV_TOKEN }}
+ name: coverage-${{ github.run_id }}
+ path: |
+ coverage.xml
+ htmlcov/
+ junit.xml
+ retention-days: 14
bdd-tests:
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index b001e6f5e..eb6f7c340 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ wheels/
.coverage.*
htmlcov/
coverage.xml
+junit.xml
# Virtual environments
.venv
diff --git a/README.md b/README.md
index 656d47556..47a178e95 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,6 @@
[](https://github.com/DataIntegrationGroup/NMSampleLocations/actions/workflows/dependabot/dependabot-updates)
[](https://github.com/DataIntegrationGroup/NMSampleLocations/actions/workflows/release.yml)
[](https://github.com/DataIntegrationGroup/NMSampleLocations/actions/workflows/tests.yml)
-[](https://codecov.io/gh/DataIntegrationGroup/NMSampleLocations)
**Geospatial Sample Data Management System**
_New Mexico Bureau of Geology and Mineral Resources_
diff --git a/codecov.yml b/codecov.yml
deleted file mode 100644
index 30ae88f35..000000000
--- a/codecov.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-# Codecov configuration.
-#
-# Both status checks are `informational: true`, so they report coverage on the
-# PR without blocking the merge. Drop that line from a check once the baseline
-# is stable enough to enforce.
-coverage:
- status:
- project:
- default:
- informational: true
- target: auto
- threshold: 1%
- patch:
- default:
- informational: true
- target: 80%
-
-comment:
- layout: "condensed_header, diff, flags, components"
- require_changes: true
-
-ignore:
- - "alembic/**"
- - "docker/**"
- - "features/**"
- - "geoserver_iac/**"
- - "scripts/**"
- - "tests/**"
From e48357ad6c6dd2dff35c40e321a18cfc9bc6591f Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 09:32:23 -0700
Subject: [PATCH 059/151] ci: exclude transfers from coverage and comment the
summary on PRs
transfers/ holds the legacy AMPAPI migration scripts. They run by hand
against SQL Server rather than from the suite, so their 6682 statements
were counted as almost entirely uncovered and dragged the reported total
down by 22 points. Omitting them moves the measured baseline from 58.15%
to 80.49% over the same 881 tests, so raise the gate from 55 to 75.
The threshold now lives in one place, COVERAGE_FAIL_UNDER, read by both
the pytest gate and the comment.
Post the summary back to the pull request, which is what the Codecov
comment used to do. scripts/coverage_pr_comment.py renders the total plus
a per-file table restricted to the Python files in the diff; `gh pr
comment --edit-last --create-if-none` keeps it to a single comment that
updates in place. It is a script rather than inline YAML so it can be run
locally against a .coverage file.
The step is continue-on-error: a comment failure should not red the
build, and GITHUB_TOKEN is read-only on pull requests from a fork. The
pull-requests: write permission is scoped to the unit-tests job, leaving
bdd-tests read-only.
Co-Authored-By: Claude Opus 5
---
.github/workflows/tests.yml | 24 +++++++++-
pyproject.toml | 3 ++
scripts/coverage_pr_comment.py | 81 ++++++++++++++++++++++++++++++++++
3 files changed, 107 insertions(+), 1 deletion(-)
create mode 100644 scripts/coverage_pr_comment.py
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 66930cd64..7c2b24364 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -14,7 +14,12 @@ jobs:
unit-tests:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+
env:
+ COVERAGE_FAIL_UNDER: "75"
MODE: development
POSTGRES_HOST: localhost
POSTGRES_PORT: 5432
@@ -95,7 +100,7 @@ jobs:
- name: Run tests
# --cov-fail-under is set here rather than in pyproject so that running a
# single test file locally does not fail on the whole-project total.
- run: uv run pytest -vv --durations=20 --cov --cov-report=xml --cov-report=html --cov-report=term-missing --cov-fail-under=55 --junitxml=junit.xml --ignore=tests/transfers
+ run: uv run pytest -vv --durations=20 --cov --cov-report=xml --cov-report=html --cov-report=term-missing --cov-fail-under="$COVERAGE_FAIL_UNDER" --junitxml=junit.xml --ignore=tests/transfers
- name: Write coverage summary
# Runs even when the coverage gate above fails, so the job summary shows
@@ -110,6 +115,23 @@ jobs:
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
+ - name: Comment coverage summary on the pull request
+ # A comment failure must not red the build, and the GITHUB_TOKEN is
+ # read-only for pull requests opened from a fork.
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ gh pr diff "$PR_NUMBER" --name-only > changed-files.txt
+ uv run python scripts/coverage_pr_comment.py \
+ --changed-files changed-files.txt \
+ --fail-under "$COVERAGE_FAIL_UNDER" > coverage-comment.md
+ gh pr comment "$PR_NUMBER" \
+ --body-file coverage-comment.md \
+ --edit-last --create-if-none
+
- name: Upload coverage reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
diff --git a/pyproject.toml b/pyproject.toml
index e7d31376e..5a86cc531 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -124,6 +124,9 @@ omit = [
"geoserver_iac/*",
"scripts/*",
"tests/*",
+ # Legacy AMPAPI migration scripts. Run by hand against SQL Server, not
+ # exercised by the suite, and large enough to dominate the total.
+ "transfers/*",
]
[tool.coverage.report]
diff --git a/scripts/coverage_pr_comment.py b/scripts/coverage_pr_comment.py
new file mode 100644
index 000000000..d9161af46
--- /dev/null
+++ b/scripts/coverage_pr_comment.py
@@ -0,0 +1,81 @@
+"""Render the coverage summary posted as a pull request comment.
+
+Reads an existing .coverage data file and writes markdown to stdout. Kept as a
+script rather than inline workflow YAML so it can be run and checked locally:
+
+ uv run python scripts/coverage_pr_comment.py --fail-under 55
+"""
+
+import argparse
+import subprocess
+import sys
+
+MARKER = ""
+
+
+def _coverage(*args: str) -> tuple[int, str]:
+ result = subprocess.run(
+ [sys.executable, "-m", "coverage", *args],
+ capture_output=True,
+ text=True,
+ )
+ return result.returncode, result.stdout.strip()
+
+
+def _changed_python_files(path: str | None) -> list[str]:
+ if not path:
+ return []
+ with open(path) as f:
+ names = [line.strip() for line in f if line.strip()]
+ return [n for n in names if n.endswith(".py")]
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--changed-files", help="file holding one changed path per line"
+ )
+ parser.add_argument("--fail-under", type=float, required=True)
+ args = parser.parse_args()
+
+ code, total = _coverage("report", "--format=total", "--precision=2")
+ if code != 0:
+ print(f"{MARKER}\n## Coverage\n\nNo coverage data was produced.")
+ return 0
+
+ total_pct = float(total)
+ verdict = "✅" if total_pct >= args.fail_under else "❌"
+
+ lines = [
+ MARKER,
+ "## Coverage",
+ "",
+ f"{verdict} **{total_pct:.2f}%** total — gate is {args.fail_under:g}%.",
+ ]
+
+ changed = _changed_python_files(args.changed_files)
+ if changed:
+ # --include is matched against the measured files, so paths the run does
+ # not track (tests, transfers, deleted files) drop out on their own.
+ code, table = _coverage(
+ "report", "--format=markdown", "--include=" + ",".join(changed)
+ )
+ if code == 0 and table:
+ lines += [
+ "",
+ "",
+ "Coverage for the Python files changed in this PR ",
+ "",
+ table,
+ "",
+ " ",
+ ]
+ else:
+ lines += ["", "_No measured coverage for the Python files changed here._"]
+
+ print("\n".join(lines))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From 1700761f3a3f1cf8f295c956ef40c9f224f017f3 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 11:46:54 -0700
Subject: [PATCH 060/151] docs(ingestion): add the automated ingestion pipeline
plan
Recovers the ticket draft written while scoping the San Acacia Reach
pipeline on 2026-08-11. It was only ever a scratchpad file, so it was
lost when the scratchpad was cleared and never reached Jira.
Covers the Dagster+ code location, dlt extraction of the Van Essen API,
a GCS raw zone, the domain mapping layer, and the direct Postgres load,
plus the watermark and backfill mechanics ported from Aqueduct.
Co-Authored-By: Claude Opus 5
---
docs/automated-ingestion-pipeline-plan.md | 316 ++++++++++++++++++++++
1 file changed, 316 insertions(+)
create mode 100644 docs/automated-ingestion-pipeline-plan.md
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
new file mode 100644
index 000000000..7374b4e0c
--- /dev/null
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -0,0 +1,316 @@
+# Draft: Automated Ingestion Pipeline Epic (BDMS)
+
+1 new Epic → 4 Tasks → 17 Sub-tasks. **Nothing written to Jira yet.**
+
+## TL;DR
+
+Build the Bureau's first automated data ingestion pipeline, in the OcotilloAPI repo, so continuous depth-to-groundwater readings reach Ocotillo on a schedule instead of by hand. San Acacia Reach (33 Van Essen divers) is the pilot source; the structure it establishes is what every later source inherits.
+
+Stack: **Dagster+** code location → **dlt** extraction → **GCS** raw parquet → **`domain/`** mapping → direct **Postgres** load. Watermark and backfill mechanics are ported from Aqueduct, with two deliberate improvements a relational destination allows: the watermark is read from Postgres rather than a GCS sidecar, and an upsert replaces Aqueduct's delete-then-repost (removing its known window where data goes temporarily missing).
+
+**Decided** — four calls already made, so reviewers don't reopen them:
+
+- **Owned by OcotilloAPI, not Aqueduct.** The loader writes over a direct database connection, which wants the `db/` SQLAlchemy models and `domain/` rules in the same process. Running it as a third Aqueduct source would mean maintaining a copy of Ocotillo's schema in another repo. Aqueduct stays the FROST/SensorThings pipeline; shared code is **ported, not imported**, so the two can diverge without breaking each other.
+- **Ground-surface datum.** `TransducerObservation.value` stores depth to water below ground surface, in feet. That picks Van Essen's `gs` arrays and drops `vrd` entirely. No measuring-point correction on ingest — `domain/water_levels.py`'s MP reconciliation belongs to the manual-measurement path, where a field crew measured the height on the day. Datum shifts are the Hydrograph Corrector's job, downstream.
+- **Public + provisional.** Visible from the first run, and marked provisional so no consumer mistakes an uncorrected diver series for a reviewed one. This matches what the retired FROST pipeline asserted for this source (`is_provisional: true`) — adopted deliberately here rather than inherited silently, which was the open question left in Aqueduct's mapping doc. It needs a schema change: `release_status` is one column, and its lexicon lists `public` and `provisional` as siblings, so visibility and maturity — two orthogonal axes — currently collide.
+- **Vendor approval flag ≠ Ocotillo review status.** Van Essen's `approvedWaterLevels*` records what *the vendor* approved. Ocotillo's `review_status` is `approved` / `not reviewed`, and `TransducerObservationBlock.reviewer_id` FKs a Bureau `Contact` — so `approved` asserts a Bureau human reviewed it. Mapping one onto the other would manufacture provenance that doesn't exist. All San Acacia blocks land `not reviewed`; the vendor flag is preserved as a separate per-row attribute.
+
+**Watch:** one external blocker (Van Essen's readings endpoint 500s, vendor-escalated) and two schema changes — a unique constraint on `transducer_observation`, and a new field because `release_status` cannot hold "public" and "provisional" at once.
+
+**Sequencing:** Task 1 gates everything. Tasks 2 and 3 run largely in parallel after it. Only two sub-tasks actually block on the vendor.
+
+## All tasks
+
+| # | Item | In one line | Blocked by |
+|---|---|---|---|
+| **T1** | **Foundations** | Package, Dagster+ code location, GCS, DB connectivity | — |
+| 1.1 | Scaffold package + Dagster skeleton | `automated_ingestion/` layout, deps, loads in `dagster dev` | — |
+| 1.2 | Register Dagster+ code location | `dagster_cloud.yaml` + prod/branch deploy workflows | 1.1 |
+| 1.3 | GCS buckets + service account | `ocotillo-ingestion-{production,staging}`, date-partitioned layout | — |
+| 1.4 | DB connectivity + least-privilege role | Cloud SQL connector from serverless; scoped Postgres role | 1.2 |
+| **T2** | **Source extraction** | Van Essen API → GCS raw zone | T1 |
+| 2.1 | Confirm endpoint + finalize mapping | **Vendor-blocked.** Resolve 500s; confirm `ts`, units, fixtures | vendor |
+| 2.2 | dlt resource: locations | 33 wells, `replace`, one call, no pagination | 1.3 |
+| 2.3 | dlt resource: readings, incremental | Per-point fetch, dlt cursor, `append`, per-entity failure isolation | 2.1 (live only) |
+| **T3** | **Domain mapping + load** | Van Essen records → Ocotillo Postgres | T1 |
+| 3.1 | Domain layer | Pure functions: units, datum, timestamps, geometry, external keys | — |
+| 3.2 | Bootstrap reference data | Reconcile 33 wells; seed parameter, sensor, deployments | 3.1 |
+| 3.3 | Represent "public but provisional" | **Schema change.** `release_status` can't hold both axes | — |
+| 3.4 | Unique constraint + upsert loader | **Schema change.** `ON CONFLICT DO UPDATE`; makes backfill idempotent | 3.2, 3.3 |
+| 3.5 | Watermark from Postgres | `MAX(observation_datetime)` per series; no GCS sidecar | 3.4 |
+| **T4** | **Backfill + operations** | Recover from gaps, bugs, and vendor corrections | T3 |
+| 4.1 | Port shared backfill primitives | `month_chunks`, `BackfillCheckpointStore`, `ChunkResult` from Aqueduct | — |
+| 4.2 | Mode A — refetch | Re-fetch from API for a window; `dry_run: true` default; chunked, resumable | 4.1, 2.3 |
+| 4.3 | Mode B — replay | Reprocess GCS parquet through the current adapter; no API calls | 4.1, 3.4 |
+| 4.4 | Schedule, observability, alerting | Daily schedule, log bridge, failure notification, run metadata | 4.2 |
+| 4.5 | Documentation | Source mapping, storage conventions, backfill runbook, new-source checklist | 4.3 |
+
+---
+
+# EPIC — Automated Ingestion Pipeline
+
+**Goal:** continuous depth-to-groundwater data lands in Ocotillo automatically, on a schedule, with no one hand-carrying files — starting with San Acacia Reach.
+
+The Hydrograph Corrector UI exists and works (BDMS-1137 done), but has no automatic supply of raw data. San Acacia Reach's 33 Van Essen divers historically flowed through the retired FROST/`st2` stack and now flow nowhere. This epic builds the supply. Correction, review, and publication workflows are **out of scope** and belong to their own epic.
+
+New top-level `automated_ingestion/` package in OcotilloAPI, deployed as its own Dagster+ code location in the existing `nmbgmr-data-services` org. dlt extracts the Van Essen API to a GCS raw zone; a `domain/` layer maps to the Ocotillo model; a loader writes to Ocotillo Postgres over a direct DB connection. Watermark and backfill mechanics come from Aqueduct.
+
+San Acacia first: unauthenticated, 33 wells, one DTW series each, and already mapped in `Aqueduct/docs/sources/san_acacia.md`. What it establishes — source registry, per-source dlt pipeline, adapter, backfill job factory — every later source inherits.
+
+**Ownership: OcotilloAPI.** Not a third Aqueduct source writing into Ocotillo. The loader writes over a direct database connection, which wants the `db/` SQLAlchemy models and `domain/` rules in-process rather than a duplicated schema in another repo. Aqueduct stays the FROST/SensorThings pipeline; this is Ocotillo's own. The two share code by porting (see below), not by importing.
+
+### Adopted from Aqueduct
+
+| Artifact | Adoption |
+|---|---|
+| `docs/BACKFILL_STRATEGY.md` | Wholesale: Mode A refetch / Mode B replay, per-source generated jobs, calendar-month chunking, `dry_run: true` default, `initial_start_date` as a floor only |
+| `shared/backfill.py`, `shared/gcs.py` | Port near-verbatim — already destination-agnostic |
+| `shared/source_registry.py` | Port the pattern; registry drives job + schedule generation |
+| `canonical/base_adapter.py` | Adapt: same `extract`/`to_*`/`run` shape and per-record failure isolation, emitting Ocotillo structs |
+| `loader/watermark_store.py` | **Adapt, not port** — see deviation 1 |
+| `docs/STORAGE_CONVENTIONS.md` | Adopt, renamed for `ocotillo-ingestion-` |
+
+### Deviations from Aqueduct
+
+1. **Watermark in Postgres, not a GCS sidecar.** Aqueduct needs `_frost_watermarks.json` because FROST has no transactional read. Ocotillo's destination does: `MAX(observation_datetime)` per `(thing_id, parameter_id)`, read in the write transaction. No sidecar drift, no recovery path.
+2. **Upsert replaces delete-then-repost.** `BACKFILL_STRATEGY.md` §4.4 accepts a temporary hole in FROST because observations have no dedup key there. Postgres does — unique constraint plus `ON CONFLICT DO UPDATE` makes load and backfill idempotent with no destructive delete. Resolves that doc's §6 open question.
+3. **Target is `Thing → Deployment → TransducerObservation`**, not `FieldEvent → … → Observation`. 5-minute diver series are continuous, not field visits.
+
+### Data classification — decided
+
+- **Datum: ground surface.** `TransducerObservation.value` = depth to water below ground surface, feet. Ingest Van Essen's `gs` arrays, not `vrd`. No measuring-point correction on ingest — `domain/water_levels.py`'s MP reconciliation is the manual-measurement path. Datum shifts are the corrector's business.
+- **Visibility public, maturity provisional.** Public from the first run, marked provisional so nobody mistakes an uncorrected diver series for a reviewed one. Matches what the old FROST pipeline asserted (`is_provisional: true`) — adopted deliberately, not inherited silently.
+- **Schema cannot express this today.** `release_status` is one scalar column (`ReleaseMixin` → `lexicon_term.term`), and its lexicon category holds `public` *and* `provisional` as siblings. Visibility and maturity are orthogonal; the lexicon conflates them. Sub-task 3.3 resolves it.
+- **Vendor approval ≠ Ocotillo review status.** `approvedWaterLevels*` records what the *vendor* approved. Ocotillo `review_status` is `approved` / `not reviewed`, and `TransducerObservationBlock.reviewer_id` FKs a Bureau `Contact` — `approved` means a Bureau human reviewed it. All San Acacia blocks land `not reviewed`; the vendor flag is kept as a separate per-row attribute.
+
+### Epic acceptance criteria
+
+- `automated_ingestion/` deploys as a Dagster+ code location on merge; jobs visible in the Dagster UI.
+- Scheduled job runs end to end: Van Essen API → GCS parquet → domain mapping → Ocotillo Postgres.
+- Re-running over an already-loaded window: zero duplicates, zero errors.
+- Both backfill jobs exist, default `dry_run: true`, chunk by month, resume from last completed chunk.
+- 33 wells resolve to `Thing` records — matched or created, no duplicates.
+- Readings are public, marked provisional, stored as DTW below ground surface in feet.
+- Series render in the Hydrograph Corrector.
+- Domain mapping unit-tested with no database, per `ADR4.md`.
+
+### Blocker
+
+Van Essen `GET /api/api/monitoringPoint/{project}/{id}` — the only readings endpoint — returns **HTTP 500 for every ID tried**; escalated to the vendor. Everything except live-readings verification proceeds on recorded fixtures. Tracked in sub-task 2.1.
+
+### Related
+
+BDMS-1137 (corrector zoom/selection, Done — the consumer of this data, not part of this epic) · BDMS-1090 (Wellpy Revival Discovery) · BDMS-362 (WellPy Ocotillo) · `DataIntegrationGroup/Aqueduct` · OcotilloAPI `ADR4.md`, `db/transducer.py`, `db/engine.py`
+
+---
+
+# TASK 1 — Foundations: code location, GCS, DB connectivity
+
+Nothing in this repo runs on a schedule today. This task creates the package, gets it deploying to Dagster+, provisions GCS, and proves the Dagster runtime can reach Ocotillo Postgres. Carries the workstream's two infrastructure risks: build size and serverless→Cloud SQL connectivity.
+
+**Done when:** package loads in `dagster dev`; merge deploys to prod and PRs produce branch deployments; buckets exist with a least-privilege SA; a trivial asset reads Ocotillo Postgres from both deployments; pytest/ruff/mypy pass.
+
+### 1.1 — Scaffold `automated_ingestion/` and the Dagster skeleton
+
+```
+automated_ingestion/
+├── defs/ definitions.py (entry point), assets/, jobs/backfill.py
+├── shared/ source_registry.py, backfill.py, gcs.py, http.py
+├── ocotillo/ adapter base + Ocotillo structs
+├── sources/san_acacia/ ingest / dlt_pipeline / adapter / transform / backfill
+└── tests/
+```
+
+- Layout above created; `automated_ingestion` added to `[tool.setuptools] packages` (same fix as `f33cd063` for `domain`).
+- Deps added: dagster, dagster-cloud, `dlt[filesystem,gs]`, gcsfs, pyarrow. `[tool.dagster] module_name = "automated_ingestion.defs.definitions"`.
+- `dagster dev` loads the location with no import errors; ruff/mypy cover it; pytest still green.
+
+Lives in this repo so the loader can import `db/` models and `domain/` rules rather than duplicate the schema. If the Dagster+ build proves too large, fall back to a `[project.optional-dependencies]` split.
+
+### 1.2 — Register as a Dagster+ code location with CI deploy
+
+- `dagster_cloud.yaml` declaring `ocotillo-automated-ingestion` → `automated_ingestion.defs.definitions`.
+- Prod (`main`) and branch-deployment (`pull_request`) workflows, modeled on Aqueduct's, same pinned action version.
+- `DAGSTER_CLOUD_API_TOKEN` and `ORGANIZATION_ID` secrets set.
+- Path-filtered to `automated_ingestion/**` so ordinary API PRs don't trigger a Dagster deploy.
+- Test PR yields a working branch deployment; merge yields a working prod location.
+
+Confirm whether PEX fast deploys work with this dependency set or it falls back to Docker — determines build times.
+
+### 1.3 — Provision GCS buckets and ingestion service account
+
+- `ocotillo-ingestion-production` and `-staging`.
+- dlt layout `{table_name}/year={YYYY}/month={MM}/day={DD}/{load_id}.{file_id}.{ext}`, yielding `raw_sanacaciareach/vanessen_locations/…` and `…/vanessen_readings/…`.
+- SA with `roles/storage.objectAdmin` scoped to those buckets only, wired into the Dagster+ location env.
+- Lifecycle policy set, or deferred with the reason recorded.
+
+`services/gcs_helper.py` already uses `GCS_BUCKET_NAME` for user uploads — ingestion must not reuse that variable, or a misconfiguration writes into the uploads bucket.
+
+### 1.4 — DB connectivity from Dagster+ with a least-privilege role
+
+Dagster+ Serverless is outside the VPC, so Cloud SQL's private IP is unreachable from it.
+
+- Preferred path: reuse `db/engine.py`'s `DB_DRIVER=cloudsql` mode (Cloud SQL Python Connector) — IAM auth, no VPC membership or public-IP allowlist.
+- Dedicated Postgres role: INSERT/UPDATE/SELECT on transducer, thing, location, deployment, lexicon tables only. Not the application role.
+- Credentials via Dagster+ env / Secret Manager, never committed.
+- Trivial asset proves connectivity from branch and prod; no connection leaks across runs.
+- Fallback documented: Hybrid agent in GCP.
+
+---
+
+# TASK 2 — Source extraction: Van Essen → GCS raw zone
+
+Land locations and readings untransformed in GCS as date-partitioned parquet. Raw storage is what makes Mode B replay possible — a mapping bug becomes a reprocess, not a re-fetch. Carries the external blocker.
+
+**Done when:** both land at the documented paths; readings extraction is incremental; a per-entity failure doesn't abort the run; fixtures exist so downstream work needs no live API.
+
+### 2.1 — Confirm the readings endpoint; finalize the source mapping
+
+Endpoint returns HTTP 500 for every ID; escalated to Van Essen. Some mapping details were inferred from retired FROST data, not the live API.
+
+- Vendor escalation resolved, or a workaround agreed (vendor export, alternate endpoint, SFTP drop).
+- Confirmed against live responses: `ts` format and timezone; `gs` unit is feet; `approvedWaterLevelsGs` and `unApprovedWaterLevelsGs` are the complete, non-overlapping set; whether `groundSurfaceData` elevation is needed and how it's time-scoped.
+- `drillingDepth` centimetres (÷ 30.48) confirmed, not back-calculated.
+- Fixture responses committed for tests.
+- `docs/sources/san_acacia.md` copied into OcotilloAPI and corrected.
+
+Datum and vendor-flag questions are already settled in the Epic — `vrd` is not ingested, and the vendor flag does not map to `review_status`. Blocks live verification of 2.3 and 4.2 only.
+
+### 2.2 — dlt resource: locations → GCS
+
+- `@dlt.resource(name="vanessen_locations")` on `GET /api/api/locations/sanacaciareach`; no pagination, all 33 in one response. `write_disposition="replace"`.
+- HTTP layer: timeouts, bounded retries with backoff, clear failure message. Doubled `/api/api/` segment preserved — confirmed, not a typo.
+- Asset `raw_san_acacia_locations` emits row-count metadata. Tested against fixture, no network.
+
+### 2.3 — dlt resource: readings → GCS, incremental
+
+- `@dlt.resource(name="vanessen_readings")` per monitoring point, dlt incremental cursor on reading timestamp, `write_disposition="append"`.
+- `initial_start_date` in `.dlt/config.toml`, documented as a floor for entities with no cursor yet — never a backfill lever (`BACKFILL_STRATEGY.md` §2).
+- Vendor approved/unapproved flag preserved per row.
+- Per-entity failure doesn't abort the run; failures counted and surfaced as asset metadata.
+- Asset `raw_san_acacia_readings` emits rows-ingested and entities-failed. Tested against fixtures.
+
+Blocked on 2.1 for live verification.
+
+---
+
+# TASK 3 — Domain mapping and load into Ocotillo
+
+Where this stops resembling Aqueduct: the destination is a relational database with constraints and transactions, and mapping rules belong in `domain/` per `ADR4.md`. Three risks — matching 33 wells without duplicating them, representing "public but provisional" when the schema can't, and making the write idempotent so backfill is safe.
+
+**Done when:** mapping rules are pure functions tested without a database; 33 wells resolve with no duplicates; data is public and separately marked provisional; `transducer_observation` has a unique constraint and the loader upserts against it; loading the same window twice leaves the row count unchanged; the watermark comes from Postgres.
+
+### 3.1 — Domain layer: Van Essen record → Ocotillo model
+
+Per `ADR4.md`, `domain/` imports nothing from `api/`, `db/`, `schemas/`, `services/`, and no fastapi/sqlalchemy/pydantic/httpx.
+
+`domain/van_essen.py`, pure functions:
+- `drillingDepth` cm → ft (÷ 30.48), reusing `domain/units.py` where it fits
+- reading timestamp → tz-aware UTC `datetime`
+- `gs` reading → DTW below ground surface, feet (datum fixed — see Epic)
+- `lat`/`lng` → WGS84 point (SRID 4326)
+- deterministic external key per well and per series, so repeat runs resolve to the same records
+
+Plus an adapter in Aqueduct's `BaseAdapter` shape, with the same per-record failure isolation: a bad record is logged and counted, never fatal. Domain errors subclass `ValueError`, matching the CSV importers' per-row contract. Tests need no database and no network. Every value the mapping *invents* rather than reads is listed in the module docstring with its justification.
+
+### 3.2 — Bootstrap reference data: reconcile wells, seed parameter, sensor, deployments
+
+Some of the 33 may already exist in Ocotillo under Bureau point IDs. Duplicates are the main risk — the `group_type` collision elsewhere in this database is the reminder that "looks new" isn't proof.
+
+- Reconciliation report **first**: per well, whether a matching `Thing` exists — on name, on `monitoringPoints[].name` (e.g. `SO-0125`), and on coordinate proximity. Ambiguous matches escalate to a human, never auto-merge.
+- Data migration (existing `data_migrations/` runner, already supports dry-run) creates missing `Location`/`Thing`, links existing ones. Idempotent, dry-run-clean before running for real.
+- Lexicon terms, a DTW `Parameter`, and a `VanEssenDiver` `Sensor` created if absent.
+- One `Deployment` per well (thing → sensor), `recording_interval` ~5 min where known.
+- Van Essen `uid` (e.g. `sanacaciareach-40`) persisted as external identifier.
+- `DataProvenance` recorded for Van Essen-sourced well attributes: depth, coordinates, installation date.
+
+### 3.3 — Represent "public but provisional"
+
+`release_status` is one scalar column and its lexicon category holds `public` and `provisional` as siblings, so both cannot be set. Visibility and maturity are orthogonal axes.
+
+- Decide the representation. Recommended: keep `release_status = "public"` for visibility, add an explicit maturity field (`is_provisional` boolean, or a `data_maturity` lexicon term) on `TransducerObservation` / `TransducerObservationBlock`. Rejected alternative: overloading `review_status`, which means Bureau review and carries a `reviewer_id` FK.
+- Follow the Model Change Workflow in `CLAUDE.md`: db model → schemas → alembic migration → tests → transfer scripts.
+- Provisional state is visible wherever the data surfaces — API responses and the Hydrograph Corrector.
+- Check the blast radius of `release_status = "public"` before shipping: `services/ngwmn_helper.py` filters `Thing.release_status == "public"` for NGWMN publication. Confirm San Acacia data becoming public is intended there too.
+- Existing rows keep their current behavior; the migration has a defined default.
+
+### 3.4 — Unique constraint on `transducer_observation` + idempotent upsert loader
+
+`db/transducer.py` defines only an index — no unique constraint, so nothing prevents inserting the same reading twice. That absence is what forces Aqueduct's delete-then-repost in FROST.
+
+- Alembic migration adds `UniqueConstraint(thing_id, parameter_id, observation_datetime)`. Existing duplicates found and resolved first — the migration must not fail on production data.
+- Loader batches and issues `INSERT … ON CONFLICT … DO UPDATE`, through the `db/` SQLAlchemy models, not raw SQL. Batch size tuned and documented; a full backfill month fits in memory; each batch commits in its own transaction.
+- `TransducerObservationBlock` rows created/extended for the loaded window, `review_status = "not reviewed"`.
+- Loader reports rows inserted, rows updated, adapter failures as Dagster metadata.
+- Test: loading the same window twice leaves the row count unchanged.
+
+### 3.5 — Watermark from Postgres
+
+- Keep Aqueduct's `WatermarkStore` interface; Postgres implementation returns `MAX(observation_datetime)` for a `(thing_id, parameter_id)`, read in the same session as the write. No GCS sidecar for normal runs.
+- Backfill never advances the normal watermark implicitly — inherent with upsert, but asserted in a test.
+- In-memory implementation kept for tests. First-ever run for a series falls back to the `initial_start_date` floor.
+- Divergence from Aqueduct recorded in the module docstring, so it reads as a decision not an oversight.
+
+---
+
+# TASK 4 — Backfill and operations
+
+A forward-only pipeline isn't enough. `BACKFILL_STRATEGY.md` §3 lists twelve situations demanding backfill; most come from ongoing operation, not onboarding — outage gaps, vendor corrections, adapter bugs found later, newly mapped properties.
+
+**Done when:** both backfill jobs are registry-generated, unscheduled, default `dry_run: true`, chunk by month sequentially in one run, and resume from the last completed chunk; the daily pipeline is scheduled and alerts a human on failure; docs carry the runbook.
+
+### 4.1 — Port shared backfill primitives from Aqueduct
+
+- `month_chunks()`, `ChunkResult`, `sum_chunk_results()`, `parse_backfill_date()`, `validate_date_order()`, `attach_run_timestamp()`, `sanitize_run_key()`, `resolve_location_ids()`, `chunk_key()`, `BackfillCheckpointStore` → `automated_ingestion/shared/backfill.py`. `atomic_write_json_with_retry()` → `shared/gcs.py`.
+- `ChunkResult` adjusted for Postgres: `rows_upserted` replaces `observations_posted`/`observations_deleted`.
+- Aqueduct's tests ported alongside and passing.
+- Each docstring notes provenance and what changed, so the two can be diffed later.
+
+### 4.2 — Backfill Mode A (refetch)
+
+Covers data never ingested: onboarding, a late-added well, an outage gap beyond the retry budget, a vendor correction, extending history past the original floor (§3A).
+
+- `san_acacia_backfill_refetch`, generated from the registry via a factory so a second source needs a registry entry, not new wiring. No schedule; launched from the Launchpad.
+- Run config: `location_ids` (empty = every location the API returns), `start_date`, `end_date`, `run_key`, `dry_run`.
+- **`dry_run: true` default.** Logs the full plan — entities, range, chunk list, expected counts — making exactly one read-only API call to resolve and validate the entity list, writing nothing.
+- An unknown `location_id` fails the run naming the bad IDs, rather than silently backfilling nothing.
+- Calendar-month chunks, sequential within one Dagster run — one billed run regardless of chunk count.
+- Ingest writes to `vanessen_backfill_readings` under isolated dlt pipeline state, so backfill can't roll back or race the scheduled cursor.
+- A chunk checkpoints only after ingest + transform + load all succeed; same `run_key` resumes from the last completed chunk.
+- Same idempotent upsert as normal load — no delete step, no window where data is missing.
+- Metadata reports per-chunk and total rows ingested, rows upserted, adapter failures.
+
+### 4.3 — Backfill Mode B (replay)
+
+Covers raw already in GCS with only the mapping wrong: adapter or unit bug, newly mapped property, storage migration, upstream rename, Ocotillo-side loss with parquet intact (§3B). Aqueduct notes this is almost entirely generic — build it that way.
+
+- `san_acacia_backfill_replay` from the same factory. Never contacts the Van Essen API.
+- Reads raw parquet for an explicit range, filtered on event time, re-running the source's *current* adapter — so fixing a domain bug and replaying picks it up automatically.
+- Same chunking, checkpointing, `dry_run: true` default, and upsert load path as Mode A.
+- Source-agnostic: a second source gets replay free once it has an adapter and a registry entry. Anything that can't be generic is called out in the docstring.
+- Test: a deliberately wrong mapping, once corrected, is fully repaired by a replay over the affected window.
+
+### 4.4 — Schedule, observability, alerting
+
+- `san_acacia_schedule` runs the daily pipeline; cron avoids contention with existing Dagster+ jobs in the org, recorded in the source registry.
+- Dagster logs bridge into the repo's existing logging setup, so ingestion failures surface where the team already looks. Confirm which error-tracking destination is current before wiring this — do not assume the repo's existing integrations are live.
+- A failed run notifies someone — not discovered via a stale hydrograph.
+- Every run emits rows ingested, rows upserted, entities processed, entities failed, adapter failures, resulting watermark per series.
+- A zero-new-rows run succeeds and is distinguishable in the logs from a failure.
+
+### 4.5 — Documentation
+
+- `docs/sources/san_acacia.md` — confirmed mapping.
+- `docs/ingestion-storage-conventions.md` — bucket/dataset/table naming, date partitioning, control-file convention, checklist for adding a source or agency.
+- `docs/ingestion-backfill.md` — Modes A and B, chunking, checkpoints, `dry_run` policy, and why Ocotillo upserts where Aqueduct deletes-then-reposts.
+- `automated_ingestion/README.md` — architecture, local dev, deploy path. Runbook: launching each mode, reading a dry-run plan, recovering a failed run.
+- `CLAUDE.md` section pointing at the above, in the style of the existing "Domain Rules" section.
+- "Adding a new source" checklist usable without reading the San Acacia implementation.
+
+---
+
+## Open questions
+
+1. **Provisional representation** (3.3) — new boolean, new lexicon category, or something else? Recommendation is in the sub-task.
+2. **NGWMN** — `release_status = "public"` makes San Acacia wells eligible for NGWMN publication via `services/ngwmn_helper.py`. Intended?
+3. **Epic name** — keep "Automated Ingestion Pipeline", or use "Hydrograph Corrector" as originally asked?
From 0a2109e8a514c618d794cc317665b33777135ac0 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 11:58:26 -0700
Subject: [PATCH 061/151] feat(ingestion): scaffold the automated_ingestion
Dagster code location
Creates the package layout the plan calls for -- defs/, shared/, ocotillo/,
sources/san_acacia/ -- and wires it up as a loadable Dagster code location via
[tool.dagster] module_name. A heartbeat asset that touches nothing makes a
failed deploy distinguishable from a failed credential.
dagster and dlt go in a new `ingestion` dependency group rather than in
`dependencies`, following the precedent set by the `cli` group: the API runtime
never imports this package, so its dependency tree should not ship in the API
image. CI syncs the group so these tests run; a conftest guard skips the
directory when it is absent, so a plain `uv sync` does not break the API suite.
Only the source registry has behavior worth testing so far. The remaining
modules carry the docstring describing what fills them and under which task.
Co-Authored-By: Claude Opus 5
---
.github/workflows/tests.yml | 4 +-
automated_ingestion/__init__.py | 33 +
automated_ingestion/defs/__init__.py | 18 +
automated_ingestion/defs/assets/__init__.py | 33 +
automated_ingestion/defs/assets/heartbeat.py | 41 +
automated_ingestion/defs/definitions.py | 30 +
automated_ingestion/defs/jobs/__init__.py | 18 +
automated_ingestion/defs/jobs/backfill.py | 30 +
automated_ingestion/ocotillo/__init__.py | 24 +
automated_ingestion/ocotillo/adapter.py | 46 +
automated_ingestion/ocotillo/structs.py | 44 +
automated_ingestion/shared/__init__.py | 18 +
automated_ingestion/shared/backfill.py | 24 +
automated_ingestion/shared/gcs.py | 34 +
automated_ingestion/shared/http.py | 24 +
automated_ingestion/shared/source_registry.py | 64 ++
automated_ingestion/sources/__init__.py | 18 +
.../sources/san_acacia/__init__.py | 27 +
.../sources/san_acacia/adapter.py | 18 +
.../sources/san_acacia/backfill.py | 18 +
.../sources/san_acacia/dlt_pipeline.py | 24 +
.../sources/san_acacia/ingest.py | 18 +
.../sources/san_acacia/transform.py | 23 +
automated_ingestion/tests/__init__.py | 18 +
automated_ingestion/tests/conftest.py | 29 +
automated_ingestion/tests/test_definitions.py | 45 +
.../tests/test_source_registry.py | 59 ++
pyproject.toml | 37 +-
uv.lock | 964 +++++++++++++++++-
29 files changed, 1779 insertions(+), 4 deletions(-)
create mode 100644 automated_ingestion/__init__.py
create mode 100644 automated_ingestion/defs/__init__.py
create mode 100644 automated_ingestion/defs/assets/__init__.py
create mode 100644 automated_ingestion/defs/assets/heartbeat.py
create mode 100644 automated_ingestion/defs/definitions.py
create mode 100644 automated_ingestion/defs/jobs/__init__.py
create mode 100644 automated_ingestion/defs/jobs/backfill.py
create mode 100644 automated_ingestion/ocotillo/__init__.py
create mode 100644 automated_ingestion/ocotillo/adapter.py
create mode 100644 automated_ingestion/ocotillo/structs.py
create mode 100644 automated_ingestion/shared/__init__.py
create mode 100644 automated_ingestion/shared/backfill.py
create mode 100644 automated_ingestion/shared/gcs.py
create mode 100644 automated_ingestion/shared/http.py
create mode 100644 automated_ingestion/shared/source_registry.py
create mode 100644 automated_ingestion/sources/__init__.py
create mode 100644 automated_ingestion/sources/san_acacia/__init__.py
create mode 100644 automated_ingestion/sources/san_acacia/adapter.py
create mode 100644 automated_ingestion/sources/san_acacia/backfill.py
create mode 100644 automated_ingestion/sources/san_acacia/dlt_pipeline.py
create mode 100644 automated_ingestion/sources/san_acacia/ingest.py
create mode 100644 automated_ingestion/sources/san_acacia/transform.py
create mode 100644 automated_ingestion/tests/__init__.py
create mode 100644 automated_ingestion/tests/conftest.py
create mode 100644 automated_ingestion/tests/test_definitions.py
create mode 100644 automated_ingestion/tests/test_source_registry.py
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 650a748b3..fb68c00e7 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -86,7 +86,7 @@ jobs:
key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('uv.lock') }}
- name: Install the project
- run: uv sync --locked --all-extras --dev --group cli
+ run: uv sync --locked --all-extras --dev --group cli --group ingestion
- name: Show Alembic heads
run: uv run alembic heads
@@ -214,7 +214,7 @@ jobs:
key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('uv.lock') }}
- name: Install the project
- run: uv sync --locked --all-extras --dev --group cli
+ run: uv sync --locked --all-extras --dev --group cli --group ingestion
- name: Show Alembic heads
run: uv run alembic heads
diff --git a/automated_ingestion/__init__.py b/automated_ingestion/__init__.py
new file mode 100644
index 000000000..1fb584ba5
--- /dev/null
+++ b/automated_ingestion/__init__.py
@@ -0,0 +1,33 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Automated ingestion: scheduled pipelines that land external monitoring data in
+Ocotillo without anyone hand-carrying a file.
+
+This package is deployed as its own Dagster+ code location, separate from the
+API process, but it lives in this repository so the loader can import ``db/``
+models and ``domain/`` rules directly instead of maintaining a second copy of
+the Ocotillo schema elsewhere.
+
+Shape of a source: a dlt pipeline extracts the vendor API into a GCS raw zone,
+an adapter maps raw records onto Ocotillo structures, and a loader writes them
+to Postgres over a direct connection. San Acacia Reach (Van Essen divers) is
+the first source; ``shared/`` holds what later sources reuse.
+
+See ``docs/automated-ingestion-pipeline-plan.md``.
+"""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/defs/__init__.py b/automated_ingestion/defs/__init__.py
new file mode 100644
index 000000000..4bfea2869
--- /dev/null
+++ b/automated_ingestion/defs/__init__.py
@@ -0,0 +1,18 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Dagster definitions: the code location's assets, jobs, and schedules."""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/defs/assets/__init__.py b/automated_ingestion/defs/assets/__init__.py
new file mode 100644
index 000000000..37d1d2fed
--- /dev/null
+++ b/automated_ingestion/defs/assets/__init__.py
@@ -0,0 +1,33 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Asset collection for the code location.
+
+Per-source assets are declared in their own modules and gathered here so
+``definitions.py`` never has to know which sources exist.
+"""
+
+from dagster import AssetsDefinition
+
+from automated_ingestion.defs.assets.heartbeat import ingestion_heartbeat
+
+
+def all_assets() -> list[AssetsDefinition]:
+ """Every asset the code location exposes."""
+ return [ingestion_heartbeat]
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/defs/assets/heartbeat.py b/automated_ingestion/defs/assets/heartbeat.py
new file mode 100644
index 000000000..2fe21ee28
--- /dev/null
+++ b/automated_ingestion/defs/assets/heartbeat.py
@@ -0,0 +1,41 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+A trivial asset that proves the code location deploys and materializes.
+
+It touches nothing -- no database, no network, no GCS -- so a failure here is
+unambiguously a packaging or deployment problem rather than a credential or
+connectivity one. The Postgres connectivity check that BDMS task 1.4 calls for
+is a separate asset, added when the least-privilege role exists.
+"""
+
+from datetime import datetime, timezone
+
+from dagster import AssetExecutionContext, asset
+
+
+@asset(
+ group_name="operations",
+ description="Static heartbeat proving the code location loaded and can run.",
+)
+def ingestion_heartbeat(context: AssetExecutionContext) -> str:
+ """Return the materialization timestamp."""
+ stamp = datetime.now(timezone.utc).isoformat()
+ context.log.info("automated_ingestion code location alive at %s", stamp)
+ return stamp
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/defs/definitions.py b/automated_ingestion/defs/definitions.py
new file mode 100644
index 000000000..2b525dded
--- /dev/null
+++ b/automated_ingestion/defs/definitions.py
@@ -0,0 +1,30 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Entry point for the ``ocotillo-automated-ingestion`` Dagster+ code location.
+
+``[tool.dagster] module_name`` in ``pyproject.toml`` points here, so this is
+what ``dagster dev`` and the Dagster+ agent import. Keep it thin: it collects
+definitions declared elsewhere in the package rather than declaring them here.
+"""
+
+from dagster import Definitions
+
+from automated_ingestion.defs.assets import all_assets
+
+defs = Definitions(assets=all_assets())
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/defs/jobs/__init__.py b/automated_ingestion/defs/jobs/__init__.py
new file mode 100644
index 000000000..a33f53655
--- /dev/null
+++ b/automated_ingestion/defs/jobs/__init__.py
@@ -0,0 +1,18 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Jobs: backfill and any other non-schedule-driven runs."""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/defs/jobs/backfill.py b/automated_ingestion/defs/jobs/backfill.py
new file mode 100644
index 000000000..18236a0b2
--- /dev/null
+++ b/automated_ingestion/defs/jobs/backfill.py
@@ -0,0 +1,30 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Backfill job factory.
+
+Two modes are planned, both filled in under BDMS task 4:
+
+- **Mode A (refetch)** re-pulls a window from the vendor API when a gap is real
+ data we never collected.
+- **Mode B (replay)** reprocesses parquet already in the GCS raw zone through
+ the current adapter, with no API calls, when the bug was in our mapping.
+
+Both chunk the window, checkpoint per chunk so an interrupted run resumes, and
+default to ``dry_run=True``.
+"""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/ocotillo/__init__.py b/automated_ingestion/ocotillo/__init__.py
new file mode 100644
index 000000000..1b346fc5d
--- /dev/null
+++ b/automated_ingestion/ocotillo/__init__.py
@@ -0,0 +1,24 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+The Ocotillo-facing half of ingestion: adapters and the structures they emit.
+
+Source packages know their vendor's payload shape; this package knows
+Ocotillo's. An adapter is the seam between them, so adding a source means
+writing an adapter rather than touching the loader.
+"""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/ocotillo/adapter.py b/automated_ingestion/ocotillo/adapter.py
new file mode 100644
index 000000000..7ba38f2f6
--- /dev/null
+++ b/automated_ingestion/ocotillo/adapter.py
@@ -0,0 +1,46 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Base class for source adapters.
+
+An adapter converts one source's raw records into Ocotillo structures. It is
+the only place a vendor's vocabulary appears alongside Ocotillo's, which keeps
+vendor quirks out of ``domain/`` and out of the loader.
+"""
+
+from abc import ABC, abstractmethod
+from collections.abc import Iterable, Iterator
+from typing import Any
+
+from automated_ingestion.ocotillo.structs import ObservationRecord
+
+
+class SourceAdapter(ABC):
+ """Maps one source's raw records onto Ocotillo structures."""
+
+ @property
+ @abstractmethod
+ def source_key(self) -> str:
+ """Registry key of the source this adapter serves."""
+
+ @abstractmethod
+ def to_observations(
+ self, records: Iterable[dict[str, Any]]
+ ) -> Iterator[ObservationRecord]:
+ """Convert raw vendor records into observation records."""
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/ocotillo/structs.py b/automated_ingestion/ocotillo/structs.py
new file mode 100644
index 000000000..90cc29868
--- /dev/null
+++ b/automated_ingestion/ocotillo/structs.py
@@ -0,0 +1,44 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Plain structures passed from an adapter to the loader.
+
+These are deliberately not SQLAlchemy models. An adapter is pure and testable
+without a database session; turning these into rows is the loader's job.
+"""
+
+from dataclasses import dataclass
+from datetime import datetime
+
+
+@dataclass(frozen=True)
+class ObservationRecord:
+ """One timestamped reading, already in Ocotillo's units and datum."""
+
+ external_point_id: str
+ """The vendor's identifier for the monitoring point."""
+
+ observation_datetime: datetime
+ """Timezone-aware instant of the reading."""
+
+ value: float
+ """Measurement in ``units``, on the datum the source's mapping fixes."""
+
+ units: str
+ """Unit symbol as it appears in the Ocotillo lexicon."""
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/shared/__init__.py b/automated_ingestion/shared/__init__.py
new file mode 100644
index 000000000..809f48e4e
--- /dev/null
+++ b/automated_ingestion/shared/__init__.py
@@ -0,0 +1,18 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Source-agnostic machinery reused by every ingestion source."""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/shared/backfill.py b/automated_ingestion/shared/backfill.py
new file mode 100644
index 000000000..e884f36f2
--- /dev/null
+++ b/automated_ingestion/shared/backfill.py
@@ -0,0 +1,24 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Backfill primitives shared by every source.
+
+Ported from Aqueduct under BDMS task 4.1 -- ``month_chunks``, ``ChunkResult``,
+and ``BackfillCheckpointStore``. Ported rather than imported: the two
+repositories deploy separately and are allowed to diverge.
+"""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/shared/gcs.py b/automated_ingestion/shared/gcs.py
new file mode 100644
index 000000000..3bd6293ab
--- /dev/null
+++ b/automated_ingestion/shared/gcs.py
@@ -0,0 +1,34 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+GCS raw-zone conventions.
+
+Every source writes date-partitioned parquet under one bucket per environment,
+so a replay backfill can select an exact window by prefix without reading the
+files.
+
+``services/gcs_helper.py`` serves user uploads from ``GCS_BUCKET_NAME``.
+Ingestion deliberately reads a different variable: sharing it would let a
+misconfigured deployment write raw vendor payloads into the uploads bucket.
+"""
+
+BUCKET_ENV_VAR = "INGESTION_GCS_BUCKET"
+"""Environment variable naming the raw-zone bucket. Never ``GCS_BUCKET_NAME``."""
+
+RAW_LAYOUT = "{table_name}/year={YYYY}/month={MM}/day={DD}/{load_id}.{file_id}.{ext}"
+"""dlt filesystem layout for the raw zone."""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/shared/http.py b/automated_ingestion/shared/http.py
new file mode 100644
index 000000000..32adeb87d
--- /dev/null
+++ b/automated_ingestion/shared/http.py
@@ -0,0 +1,24 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+HTTP session construction for vendor APIs.
+
+Centralized so every source inherits the same timeout, retry, and backoff
+posture, and so one source's flaky endpoint cannot hang a run indefinitely.
+Filled in alongside the first live extraction under BDMS task 2.
+"""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/shared/source_registry.py b/automated_ingestion/shared/source_registry.py
new file mode 100644
index 000000000..a5ef76ab6
--- /dev/null
+++ b/automated_ingestion/shared/source_registry.py
@@ -0,0 +1,64 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Registry of ingestion sources.
+
+Each source declares itself once here so jobs, schedules, and the backfill
+factory can enumerate sources without importing each one by name.
+"""
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class SourceDefinition:
+ """Static description of one ingestion source."""
+
+ key: str
+ """Stable identifier, used in asset keys and GCS prefixes."""
+
+ display_name: str
+ """Human-readable name for logs and the Dagster UI."""
+
+ dataset_name: str
+ """dlt dataset name; becomes the top-level GCS prefix."""
+
+
+_SOURCES: dict[str, SourceDefinition] = {}
+
+
+def register(source: SourceDefinition) -> SourceDefinition:
+ """Add a source to the registry, rejecting duplicate keys."""
+ if source.key in _SOURCES:
+ raise ValueError(f"Source {source.key!r} is already registered.")
+ _SOURCES[source.key] = source
+ return source
+
+
+def get_source(key: str) -> SourceDefinition:
+ """Look up a registered source by key."""
+ try:
+ return _SOURCES[key]
+ except KeyError:
+ raise KeyError(f"No ingestion source registered under {key!r}.") from None
+
+
+def all_sources() -> tuple[SourceDefinition, ...]:
+ """Every registered source, ordered by key."""
+ return tuple(_SOURCES[k] for k in sorted(_SOURCES))
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/sources/__init__.py b/automated_ingestion/sources/__init__.py
new file mode 100644
index 000000000..4fbe9cf77
--- /dev/null
+++ b/automated_ingestion/sources/__init__.py
@@ -0,0 +1,18 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""One subpackage per ingestion source."""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/sources/san_acacia/__init__.py b/automated_ingestion/sources/san_acacia/__init__.py
new file mode 100644
index 000000000..17e2771f8
--- /dev/null
+++ b/automated_ingestion/sources/san_acacia/__init__.py
@@ -0,0 +1,27 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+San Acacia Reach -- 33 Van Essen divers, one depth-to-groundwater series each.
+
+The pilot source. Unauthenticated, small, and already mapped, so it exercises
+the whole path end to end without authentication or pagination complicating the
+first build. Readings land on the **ground-surface** datum (Van Essen's ``gs``
+arrays, never ``vrd``), public but provisional, and always ``not reviewed`` --
+the vendor's own approval flag records what the vendor approved, not a Bureau
+review.
+"""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/sources/san_acacia/adapter.py b/automated_ingestion/sources/san_acacia/adapter.py
new file mode 100644
index 000000000..0d5d89731
--- /dev/null
+++ b/automated_ingestion/sources/san_acacia/adapter.py
@@ -0,0 +1,18 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Van Essen records to Ocotillo structures. Implemented under BDMS task 3.1."""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/sources/san_acacia/backfill.py b/automated_ingestion/sources/san_acacia/backfill.py
new file mode 100644
index 000000000..eb3cd127e
--- /dev/null
+++ b/automated_ingestion/sources/san_acacia/backfill.py
@@ -0,0 +1,18 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""San Acacia backfill wiring. Built under BDMS task 4."""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/sources/san_acacia/dlt_pipeline.py b/automated_ingestion/sources/san_acacia/dlt_pipeline.py
new file mode 100644
index 000000000..c521b1f55
--- /dev/null
+++ b/automated_ingestion/sources/san_acacia/dlt_pipeline.py
@@ -0,0 +1,24 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+dlt resources for the Van Essen API.
+
+Two resources: ``locations`` (33 wells, ``replace``, one call) and ``readings``
+(per-point, ``append``, incremental on the reading timestamp). Built under BDMS
+tasks 2.2 and 2.3; 2.3 waits on the vendor-side endpoint failure.
+"""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/sources/san_acacia/ingest.py b/automated_ingestion/sources/san_acacia/ingest.py
new file mode 100644
index 000000000..32160edcf
--- /dev/null
+++ b/automated_ingestion/sources/san_acacia/ingest.py
@@ -0,0 +1,18 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Dagster assets for the San Acacia source. Declared under BDMS task 2."""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/sources/san_acacia/transform.py b/automated_ingestion/sources/san_acacia/transform.py
new file mode 100644
index 000000000..7ed027ff1
--- /dev/null
+++ b/automated_ingestion/sources/san_acacia/transform.py
@@ -0,0 +1,23 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Van Essen payload reshaping that precedes adaptation.
+
+Van Essen returns parallel arrays rather than one object per reading, so this
+is where they become records.
+"""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/tests/__init__.py b/automated_ingestion/tests/__init__.py
new file mode 100644
index 000000000..6df237838
--- /dev/null
+++ b/automated_ingestion/tests/__init__.py
@@ -0,0 +1,18 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Tests for the automated ingestion code location."""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/tests/conftest.py b/automated_ingestion/tests/conftest.py
new file mode 100644
index 000000000..801f5d827
--- /dev/null
+++ b/automated_ingestion/tests/conftest.py
@@ -0,0 +1,29 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Skip this directory when the ingestion dependency group is not installed.
+
+dagster lives in the optional ``ingestion`` group, so a developer who ran a
+plain ``uv sync`` has no dagster in the environment. Without this guard,
+collecting these modules raises ``ImportError`` and takes the whole suite down
+with it -- the API tests would fail for a package the API never imports.
+"""
+
+from importlib.util import find_spec
+
+collect_ignore_glob = [] if find_spec("dagster") else ["*.py"]
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_definitions.py b/automated_ingestion/tests/test_definitions.py
new file mode 100644
index 000000000..b0745dcb0
--- /dev/null
+++ b/automated_ingestion/tests/test_definitions.py
@@ -0,0 +1,45 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+The code location loads.
+
+Cheap, but it is the check that catches the failure this package is most prone
+to: a Dagster+ deploy that builds fine and then cannot import.
+"""
+
+from dagster import AssetKey, Definitions
+
+from automated_ingestion.defs.definitions import defs
+
+
+def test_definitions_object_is_loadable():
+ assert isinstance(defs, Definitions)
+
+
+def test_heartbeat_asset_is_registered():
+ assert AssetKey(["ingestion_heartbeat"]) in defs.resolve_all_asset_keys()
+
+
+def test_heartbeat_materializes_without_external_dependencies():
+ from dagster import materialize
+
+ from automated_ingestion.defs.assets.heartbeat import ingestion_heartbeat
+
+ result = materialize([ingestion_heartbeat])
+ assert result.success
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_source_registry.py b/automated_ingestion/tests/test_source_registry.py
new file mode 100644
index 000000000..18b4e6efd
--- /dev/null
+++ b/automated_ingestion/tests/test_source_registry.py
@@ -0,0 +1,59 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Registry behavior: duplicate keys are a bug, not a silent overwrite."""
+
+import pytest
+
+from automated_ingestion.shared import source_registry
+from automated_ingestion.shared.source_registry import SourceDefinition
+
+
+@pytest.fixture(autouse=True)
+def _isolated_registry(monkeypatch):
+ monkeypatch.setattr(source_registry, "_SOURCES", {})
+
+
+def _definition(key="san_acacia"):
+ return SourceDefinition(
+ key=key,
+ display_name="San Acacia Reach",
+ dataset_name="raw_sanacaciareach",
+ )
+
+
+def test_registered_source_is_retrievable():
+ source_registry.register(_definition())
+ assert source_registry.get_source("san_acacia").display_name == "San Acacia Reach"
+
+
+def test_duplicate_key_is_rejected():
+ source_registry.register(_definition())
+ with pytest.raises(ValueError, match="already registered"):
+ source_registry.register(_definition())
+
+
+def test_unknown_key_raises():
+ with pytest.raises(KeyError, match="san_acacia"):
+ source_registry.get_source("san_acacia")
+
+
+def test_all_sources_is_sorted_by_key():
+ source_registry.register(_definition("van_essen"))
+ source_registry.register(_definition("bernco"))
+ assert [s.key for s in source_registry.all_sources()] == ["bernco", "van_essen"]
+
+
+# ============= EOF =============================================
diff --git a/pyproject.toml b/pyproject.toml
index 7b746073b..0cdc5c86a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -106,7 +106,30 @@ dependencies = [
package = true
[tool.setuptools]
-packages = ["alembic", "cli", "core", "data_migrations", "db", "domain", "schemas", "services", "transfers"]
+packages = [
+ "alembic",
+ "automated_ingestion",
+ "automated_ingestion.defs",
+ "automated_ingestion.defs.assets",
+ "automated_ingestion.defs.jobs",
+ "automated_ingestion.ocotillo",
+ "automated_ingestion.shared",
+ "automated_ingestion.sources",
+ "automated_ingestion.sources.san_acacia",
+ "cli",
+ "core",
+ "data_migrations",
+ "db",
+ "domain",
+ "schemas",
+ "services",
+ "transfers",
+]
+
+# Entry point for the `ocotillo-automated-ingestion` Dagster+ code location.
+# `dagster dev` and the Dagster+ agent both read this.
+[tool.dagster]
+module_name = "automated_ingestion.defs.definitions"
# Bare `--cov` measures every imported module, which pulls the whole virtualenv
# into the report. Scope it to first-party code instead. Keep this a single
@@ -119,6 +142,7 @@ relative_files = true
omit = [
".venv/*",
"alembic/*",
+ "automated_ingestion/tests/*",
"docker/*",
"features/*",
"geoserver_iac/*",
@@ -173,6 +197,17 @@ cli = [
"openpyxl==3.1.5",
"google-api-python-client==2.198.0",
]
+# Dagster+ code location dependencies. The API runtime never imports
+# `automated_ingestion`, so keeping these out of `dependencies` stops dagster
+# and its transitive tree from shipping in the API image. CI and the Dagster+
+# build install them explicitly with `uv sync --group ingestion`.
+ingestion = [
+ "dagster>=1.13.18",
+ "dagster-cloud>=1.13.18",
+ "dlt[filesystem,gs]>=1.30.0",
+ "gcsfs>=2026.8.0",
+ "pyarrow>=25.0.1",
+]
# timezone to use when rendering the date within the migration file
# as well as the filename.
diff --git a/uv.lock b/uv.lock
index e3dbf53cd..526f81af9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,7 +2,8 @@ version = 1
revision = 3
requires-python = ">=3.13"
resolution-markers = [
- "python_full_version >= '3.14'",
+ "python_full_version >= '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'",
+ "(python_full_version >= '3.14' and platform_python_implementation == 'PyPy') or (python_full_version >= '3.14' and sys_platform == 'emscripten')",
"python_full_version < '3.14'",
]
@@ -15,6 +16,24 @@ 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 = "aiobotocore"
+version = "3.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "aioitertools" },
+ { name = "botocore" },
+ { name = "jmespath" },
+ { name = "multidict" },
+ { name = "python-dateutil" },
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/73/c0/18abcb7e4e504a68714c280853fd180afe376a4a55e5511fb04ba76702e4/aiobotocore-3.9.0.tar.gz", hash = "sha256:5d344e97c518b010bea167c7f7ba4f9e785f9d2b8ac7af4fd00846c62f2c0a10", size = 514972, upload-time = "2026-08-01T11:54:07.673Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/30/c5/6290519dec32f3cdf6827e3bbcbf7a9f4fb29a55a9204199901fed69957b/aiobotocore-3.9.0-py3-none-any.whl", hash = "sha256:7354659eac9ba6034675b3ea178330b7de97c45989d6fda1bf01d3da167b6135", size = 100764, upload-time = "2026-08-01T11:54:06.128Z" },
+]
+
[[package]]
name = "aiofiles"
version = "24.1.0"
@@ -114,6 +133,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
]
+[[package]]
+name = "aioitertools"
+version = "0.13.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" },
+]
+
[[package]]
name = "aiosignal"
version = "1.4.0"
@@ -167,6 +195,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
+[[package]]
+name = "antlr4-python3-runtime"
+version = "4.13.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/5f/2cdf6f7aca3b20d3f316e9f505292e1f256a32089bd702034c29ebde6242/antlr4_python3_runtime-4.13.2.tar.gz", hash = "sha256:909b647e1d2fc2b70180ac586df3933e38919c85f98ccc656a96cd3f25ef3916", size = 117467, upload-time = "2024-08-03T19:00:12.757Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/89/03/a851e84fcbb85214dc637b6378121ef9a0dd61b4c65264675d8a5c9b1ae7/antlr4_python3_runtime-4.13.2-py3-none-any.whl", hash = "sha256:fe3835eb8d33daece0e799090eda89719dbccee7aa39ef94eed3818cafa5a7e8", size = 144462, upload-time = "2024-08-03T19:00:11.134Z" },
+]
+
[[package]]
name = "anyio"
version = "4.14.2"
@@ -393,6 +430,20 @@ 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 = "botocore"
+version = "1.43.56"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jmespath" },
+ { name = "python-dateutil" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b9/cc/7f84a5d3071fe878380e9f610ab36ca87b8cbbc4aa81ba2727f90e1f3ea3/botocore-1.43.56.tar.gz", hash = "sha256:6c01f85f0ff9863076f4c761e74ee3aa96c5ccc1ad09fc1efd62ef8f2d22bf57", size = 15733117, upload-time = "2026-07-24T19:31:38.125Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/cd/86fe9e659e9699f62f8dd5ecd8c6725474334b23cab8aa71d82b5f56f1a4/botocore-1.43.56-py3-none-any.whl", hash = "sha256:aafc741f1b10f6fd63253eaf6ea029680c1ff436d87e1b8969d62aefa0c76976", size = 15418773, upload-time = "2026-07-24T19:31:34.758Z" },
+]
+
[[package]]
name = "cachetools"
version = "7.1.7"
@@ -660,6 +711,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "coloredlogs"
+version = "14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "humanfriendly" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/84/1b/1ecdd371fa68839cfbda15cc671d0f6c92d2c42688df995a9bf6e36f3511/coloredlogs-14.0.tar.gz", hash = "sha256:a1fab193d2053aa6c0a97608c4342d031f1f93a3d1218432c59322441d31a505", size = 275863, upload-time = "2020-02-16T20:51:12.172Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/2f/12747be360d6dea432e7b5dfae3419132cb008535cfe614af73b9ce2643b/coloredlogs-14.0-py2.py3-none-any.whl", hash = "sha256:346f58aad6afd48444c2468618623638dadab76e4e70d5e10822676f2d32226a", size = 43888, upload-time = "2020-02-16T20:51:09.712Z" },
+]
+
[[package]]
name = "coverage"
version = "7.10.2"
@@ -781,6 +844,111 @@ 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 = "dagster"
+version = "1.13.18"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "alembic" },
+ { name = "antlr4-python3-runtime" },
+ { name = "click" },
+ { name = "coloredlogs" },
+ { name = "dagster-pipes" },
+ { name = "dagster-shared" },
+ { name = "docstring-parser" },
+ { name = "filelock" },
+ { name = "grpcio" },
+ { name = "grpcio-health-checking" },
+ { name = "jinja2" },
+ { name = "protobuf" },
+ { name = "psutil", marker = "sys_platform == 'win32'" },
+ { name = "python-dotenv" },
+ { name = "pytz" },
+ { name = "pywin32", marker = "sys_platform == 'win32'" },
+ { name = "requests" },
+ { name = "rich" },
+ { name = "six" },
+ { name = "sqlalchemy" },
+ { name = "structlog" },
+ { name = "tabulate" },
+ { name = "tomli" },
+ { name = "toposort" },
+ { name = "tqdm" },
+ { name = "tzdata" },
+ { name = "universal-pathlib" },
+ { name = "watchdog" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0c/6b/bac47b75b9ddb3301c345255e87e66d66516755a66be55374f739c3b4da4/dagster-1.13.18.tar.gz", hash = "sha256:b443164a1fad04e4da45fbb729b9ed4ffd0cbf0faf8aa7edc9f8a11cc4a29024", size = 3629353, upload-time = "2026-08-14T19:15:09.753Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/b9/0a37f7460d7391bfb20391a8944d9cc3334cffd0dbd81149efc01ca286ba/dagster-1.13.18-py3-none-any.whl", hash = "sha256:fd9cd4041245e1ae2e71660c45ad6bbc9999489b59dd07d49c09c54d798800c0", size = 2026007, upload-time = "2026-08-14T19:15:07.192Z" },
+]
+
+[[package]]
+name = "dagster-cloud"
+version = "1.13.18"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "dagster" },
+ { name = "dagster-cloud-cli" },
+ { name = "dagster-shared" },
+ { name = "pex" },
+ { name = "questionary" },
+ { name = "requests" },
+ { name = "typer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/41/1554b9b5a0ccc10488c66d41a1f03569dfe219f53a57ff8401498d7238f5/dagster_cloud-1.13.18.tar.gz", hash = "sha256:a705e6ce04d438187c46fe72a74c649fc6e7bbb18a116a2297559cab3b389331", size = 737131, upload-time = "2026-08-14T19:15:31.319Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6f/1e/b9778c03289847be65dffa8fe1ef898e518a3c37395fae4e8d2d2ad9648c/dagster_cloud-1.13.18-py3-none-any.whl", hash = "sha256:d854af1985e54600b6e8bfb34530133289b8956ee0aca5fa7312aef62eda4781", size = 204300, upload-time = "2026-08-14T19:15:29.879Z" },
+]
+
+[[package]]
+name = "dagster-cloud-cli"
+version = "1.13.18"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "dagster-shared" },
+ { name = "github3-py" },
+ { name = "jinja2" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "questionary" },
+ { name = "requests" },
+ { name = "setuptools" },
+ { name = "typer" },
+ { name = "validators" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/27/cc/4eb6b2b63489533a0d8182051a7a54acca917dcd13485ad2fbb268bd2d91/dagster_cloud_cli-1.13.18.tar.gz", hash = "sha256:33a939a0320145beab61d5db8bacde0d40ed72c77328174b130667b9ebe140af", size = 176645, upload-time = "2026-08-14T19:29:52.847Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/34/65/67d9a1f36999578b6d13517da5f64e835177312e9b6be2d11c9cd3761924/dagster_cloud_cli-1.13.18-py3-none-any.whl", hash = "sha256:7fc5900404049d1d1e8399947e74b80aa41c3ef3a42962ff1ce3e9a083c3cb25", size = 122353, upload-time = "2026-08-14T19:29:51.67Z" },
+]
+
+[[package]]
+name = "dagster-pipes"
+version = "1.13.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/90/6e38aac87786e71cabc003978a9a43aae4e5eae7755c45b7078196ae009f/dagster_pipes-1.13.18.tar.gz", hash = "sha256:29b27cdc386664e8c0842b1cc65f970dcc7c568d9e53a67732452b42cb265cd8", size = 149679, upload-time = "2026-08-14T19:15:41.361Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/41/a57cb37bded94a2f77712ed4af7d0a5306f0014313ee8aacd79b34e8778c/dagster_pipes-1.13.18-py3-none-any.whl", hash = "sha256:76eccd1d3d784223a3954a9064b787f21c96f6cb9c470f8d2e755e7ce768b529", size = 20245, upload-time = "2026-08-14T19:15:40.258Z" },
+]
+
+[[package]]
+name = "dagster-shared"
+version = "1.13.18"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "platformdirs" },
+ { name = "pydantic" },
+ { name = "pyyaml" },
+ { name = "tomlkit" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/be/c8/aa3a0b803501437906e9605af83e62bb86dd8860d8797c9d67f72ecfbbde/dagster_shared-1.13.18.tar.gz", hash = "sha256:c081b6cdb1fa79399328e2adaa22fcdf1f336b83fedc5783678b8e40b26eda33", size = 124087, upload-time = "2026-08-14T19:26:53.281Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d7/2d/b08a5071ab05243a283ca8159069904c37ec26199c9c33d9f0d32627ae7c/dagster_shared-1.13.18-py3-none-any.whl", hash = "sha256:a549a941494fc6b0a860fffcb7018025294fd63d2d804603848e9711e02c4c39", size = 96420, upload-time = "2026-08-14T19:26:51.923Z" },
+]
+
[[package]]
name = "dateparser"
version = "1.3.0"
@@ -796,6 +964,15 @@ 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 = "decorator"
+version = "5.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" },
+]
+
[[package]]
name = "distlib"
version = "0.4.0"
@@ -805,6 +982,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
+[[package]]
+name = "dlt"
+version = "1.30.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "fsspec" },
+ { name = "gitpython" },
+ { name = "giturlparse" },
+ { name = "humanize" },
+ { name = "jsonpath-ng" },
+ { name = "orjson", marker = "(python_full_version >= '3.14' and platform_python_implementation == 'PyPy') or (python_full_version >= '3.14' and sys_platform == 'emscripten') or (platform_python_implementation != 'PyPy' and sys_platform != 'emscripten')" },
+ { name = "packaging" },
+ { name = "pathvalidate" },
+ { name = "pendulum" },
+ { name = "pluggy" },
+ { name = "pytz" },
+ { name = "pywin32", marker = "sys_platform == 'win32'" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "requirements-parser" },
+ { name = "rich-argparse" },
+ { name = "semver" },
+ { name = "setuptools" },
+ { name = "simplejson" },
+ { name = "sqlglot" },
+ { name = "tenacity" },
+ { name = "tomlkit" },
+ { name = "typing-extensions" },
+ { name = "tzdata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/93/a8/fab4e86b8c9a6f7c04c5ecdb9a7d18297b6ecf6c92e13115eed033714ee7/dlt-1.30.0.tar.gz", hash = "sha256:46157b4c75aabde40c8b12af005e27d51ddde693ebbc2d338682ee0b19527d5b", size = 1155778, upload-time = "2026-08-11T13:21:57.707Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/09/7111a1dfda0b1a92648854182507df1a0c53b17cd258ea5dd206bc65d11f/dlt-1.30.0-py3-none-any.whl", hash = "sha256:7e3c66fc9f8874438539e15123c7ff4f587b5779939e5fca3a43bb3e865cbdab", size = 1432587, upload-time = "2026-08-11T13:21:59.734Z" },
+]
+
+[package.optional-dependencies]
+filesystem = [
+ { name = "botocore" },
+ { name = "s3fs" },
+]
+gs = [
+ { name = "gcsfs" },
+]
+
[[package]]
name = "dnspython"
version = "2.8.0"
@@ -814,6 +1036,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
+[[package]]
+name = "docstring-parser"
+version = "0.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
+]
+
[[package]]
name = "dotenv"
version = "0.9.9"
@@ -1014,6 +1245,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
+[[package]]
+name = "fsspec"
+version = "2026.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" },
+]
+
+[[package]]
+name = "gcsfs"
+version = "2026.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "decorator" },
+ { name = "fsspec" },
+ { name = "google-auth" },
+ { name = "google-auth-oauthlib" },
+ { name = "google-cloud-storage" },
+ { name = "google-cloud-storage-control" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/68/3a/194b5e67b78586a45fb36958800a30783c24c07b7f1c0e82d347f01346c6/gcsfs-2026.8.0.tar.gz", hash = "sha256:c2a7c0ffee2d0837243b4f838efaa185c152a4eb4cc529e41b3569bf00b9781e", size = 1090432, upload-time = "2026-08-13T11:46:49.018Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/a0/ad756cfa675322303eba197fdd87ff6dd5b6fc0425ade6e6ee2348c94418/gcsfs-2026.8.0-py3-none-any.whl", hash = "sha256:adf616a543ac38557ae87dcf6a282020fad54cdd6778cc9e30ab01a85ef91fdc", size = 91402, upload-time = "2026-08-13T11:46:47.359Z" },
+]
+
[[package]]
name = "geoalchemy2"
version = "0.20.0"
@@ -1027,6 +1286,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/08/b66ad4239f592e05202e25925c08cdd04cc14c3994000ec70ec61fea202c/geoalchemy2-0.20.0-py3-none-any.whl", hash = "sha256:1489a1d106519542a79c97cd0b4c537d80462c353610ebc2429cf2c43daac717", size = 96467, upload-time = "2026-05-12T14:50:24.998Z" },
]
+[[package]]
+name = "gitdb"
+version = "4.0.12"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "smmap" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
+]
+
+[[package]]
+name = "github3-py"
+version = "4.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyjwt", extra = ["crypto"] },
+ { name = "python-dateutil" },
+ { name = "requests" },
+ { name = "uritemplate" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/89/91/603bcaf8cd1b3927de64bf56c3a8915f6653ea7281919140c5bcff2bfe7b/github3.py-4.0.1.tar.gz", hash = "sha256:30d571076753efc389edc7f9aaef338a4fcb24b54d8968d5f39b1342f45ddd36", size = 36214038, upload-time = "2023-04-26T17:56:37.677Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/ad/2394d4fb542574678b0ba342daf734d4d811768da3c2ee0c84d509dcb26c/github3.py-4.0.1-py3-none-any.whl", hash = "sha256:a89af7de25650612d1da2f0609622bcdeb07ee8a45a1c06b2d16a05e4234e753", size = 151800, upload-time = "2023-04-26T17:56:25.015Z" },
+]
+
+[[package]]
+name = "gitpython"
+version = "3.1.59"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "gitdb" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ca/dc/126b28e76b24a9268ba931ad3e012f71ebdadf62fd9f17758f7074bb0b20/gitpython-3.1.59.tar.gz", hash = "sha256:0a1475cfdc38a5bfba1a3e9a4a9da52a39749ecec322b772915c019f94e5b7e4", size = 230445, upload-time = "2026-08-10T12:03:20.271Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl", hash = "sha256:67a82f537384578643624c8b2c531938a9b82be431663e575dcf638526631d4c", size = 220996, upload-time = "2026-08-10T12:03:18.804Z" },
+]
+
+[[package]]
+name = "giturlparse"
+version = "0.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/8a/b70b84cc78f9059d627f09560c81a11e8f046570d220a24e169489cad6c9/giturlparse-0.15.0.tar.gz", hash = "sha256:9af3f1fd5c4a0cac94ddb283593635005646393ee0debbe330d1bdff8866bf2c", size = 16138, upload-time = "2026-06-16T07:28:56.021Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/96/147a2771ab655b9353781fb2f95c94eaf1d8576dccc991c1a61d0d355067/giturlparse-0.15.0-py2.py3-none-any.whl", hash = "sha256:76d2e6983b037356ab99b30683e533ac3db96409b68e2163a20fc3aff6446f10", size = 16683, upload-time = "2026-06-16T07:28:55.184Z" },
+]
+
[[package]]
name = "google-api-core"
version = "2.34.0"
@@ -1043,6 +1350,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/c1/a8a92ae1bc4b1a8f804c776d7d3f0c771b78a62c3ad4df1be41b3fd8c767/google_api_core-2.34.0-py3-none-any.whl", hash = "sha256:cdf9c67e7ca2402d86ccbfde5f2503fc83e3cc3f58cc78456ae96cad24a6d2de", size = 180545, upload-time = "2026-08-06T06:22:47.502Z" },
]
+[package.optional-dependencies]
+grpc = [
+ { name = "grpcio" },
+ { name = "grpcio-status" },
+]
+
[[package]]
name = "google-api-python-client"
version = "2.198.0"
@@ -1085,6 +1398,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/97/be/954c35a62b9e31de66b0a43c225c9b6bb9e0f98d6b1dc110a2308e3644f5/google_auth_httplib2-0.4.0-py3-none-any.whl", hash = "sha256:8e55cfafa3358cba85f6cad4a886138e88e158d71e7e5c9ee5936a5c1507fb91", size = 9529, upload-time = "2026-05-07T08:02:12.375Z" },
]
+[[package]]
+name = "google-auth-oauthlib"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-auth" },
+ { name = "requests-oauthlib" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/70/18/90c7fac516e63cf2058166fce0c88c353647c677b51cc036c09c49bb5cbb/google_auth_oauthlib-1.4.0.tar.gz", hash = "sha256:18b5e28880eb8eba9065c436becdc0ee8e4b59117a73a510679c82f70cd363d2", size = 21675, upload-time = "2026-05-07T08:03:47.816Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/d3/d7dff0d58a9e9244b48044bfb6a898bfcc8ecc42e0031d1bebc695344725/google_auth_oauthlib-1.4.0-py3-none-any.whl", hash = "sha256:251314f213a9ee46a5ae73988e84fd7cca8bb68e7ecf4bfd45940f9e7f51d070", size = 19261, upload-time = "2026-05-07T08:02:13.798Z" },
+]
+
[[package]]
name = "google-cloud-core"
version = "2.6.1"
@@ -1115,6 +1441,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/06/6f/d69f0e185e08ddb58c323a0a935af2b492907b5de362bc08933b0a3b5644/google_cloud_storage-3.13.1-py3-none-any.whl", hash = "sha256:98208de6c21e85cecd3eb44551894efff33d98365500e178867d4305854a770a", size = 341486, upload-time = "2026-08-06T06:23:36.548Z" },
]
+[[package]]
+name = "google-cloud-storage-control"
+version = "1.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-api-core", extra = ["grpc"] },
+ { name = "google-auth" },
+ { name = "grpc-google-iam-v1" },
+ { name = "grpcio" },
+ { name = "proto-plus" },
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3e/65/b90fe3397596f7066336cf3979fd147aea68e1fcea10d71dd1f8dab974bc/google_cloud_storage_control-1.13.0.tar.gz", hash = "sha256:48351122e3375d2f00a393d6fbfed929c614246a2bd27c9410382b615a4641c6", size = 153274, upload-time = "2026-08-06T06:24:40.386Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/00/937a64affecabc07bdf71041f463d044e3064be7133aa0176a6f1612793f/google_cloud_storage_control-1.13.0-py3-none-any.whl", hash = "sha256:6c8b0b922c38eb1614b5b6bf5d388b581c50a59705c00ec53d9ddd8c36470066", size = 110217, upload-time = "2026-08-06T06:23:35.2Z" },
+]
+
[[package]]
name = "google-crc32c"
version = "1.8.0"
@@ -1157,6 +1500,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/51/186c02b8549b69ccda44429cf6ff5081e4b61a602ddfe6a8020d1be31d1b/googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79", size = 300626, upload-time = "2026-08-06T06:23:46.696Z" },
]
+[package.optional-dependencies]
+grpc = [
+ { name = "grpcio" },
+]
+
[[package]]
name = "greenlet"
version = "3.5.5"
@@ -1214,6 +1562,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" },
]
+[[package]]
+name = "grpc-google-iam-v1"
+version = "0.14.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "googleapis-common-protos", extra = ["grpc"] },
+ { name = "grpcio" },
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d2/d0/fa5bdd5f3f421bb68dc6dc162e9caaf942897ca41ce7255b524723c80f0b/grpc_google_iam_v1-0.14.5.tar.gz", hash = "sha256:07fd3a9fafb586588e771831fbfc8f6597050181d0c3b45e039d18b8fdc1aab5", size = 23736, upload-time = "2026-08-06T06:24:54.489Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/ab/be3ad0d46cffe35fd1e7cc3f9947edd6cb3c552229de3be2742f15f7ea47/grpc_google_iam_v1-0.14.5-py3-none-any.whl", hash = "sha256:0f5e680b20aa0a9441e68c769da04d94d70fca4e43751a82d8abb8aa6a7181ca", size = 32674, upload-time = "2026-08-06T06:23:49.467Z" },
+]
+
[[package]]
name = "grpcio"
version = "1.83.0"
@@ -1245,6 +1607,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" },
]
+[[package]]
+name = "grpcio-health-checking"
+version = "1.81.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "grpcio" },
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/65/46/6b6678b5a922765ae7637205bb6d0618a4da8b35f2ce6116f8bcff262370/grpcio_health_checking-1.81.1.tar.gz", hash = "sha256:ecc61480e25058a4a04e11e4ab6900ad7439b32e60a8ce4ece7d9f219221c85d", size = 17107, upload-time = "2026-06-11T12:58:49.632Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/87/174bbfb5613794862c45b5cdf567fcfb478ad5a3a7b594e11d998656b2f9/grpcio_health_checking-1.81.1-py3-none-any.whl", hash = "sha256:cbc6a4171825ec64389de2f062d296ba129a5c27eefd0dd55fa837909184bdf9", size = 19120, upload-time = "2026-06-11T12:58:38.008Z" },
+]
+
+[[package]]
+name = "grpcio-status"
+version = "1.83.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "googleapis-common-protos" },
+ { name = "grpcio" },
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/fd/848dd7e009de85f8ca59999d1cc618ff8ebf7ea5636d083a47455d212d24/grpcio_status-1.83.0.tar.gz", hash = "sha256:837219c6de9afdccb6f6f72b34bc71e151a2011ef04040e3faaca746a57e54ae", size = 13965, upload-time = "2026-07-23T15:24:26.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d6/00/73204406228cf989bea6b0fd9fe4702fab49a8a152a0c6f90856dadb6ac7/grpcio_status-1.83.0-py3-none-any.whl", hash = "sha256:f6a838a7c5fb84ae98833ec0ef81ed438c26e11e54b2ddb8e92ad328c861de69", size = 14636, upload-time = "2026-07-23T15:23:49.044Z" },
+]
+
[[package]]
name = "gunicorn"
version = "23.0.0"
@@ -1306,6 +1695,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
+[[package]]
+name = "humanfriendly"
+version = "10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyreadline3", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
+]
+
+[[package]]
+name = "humanize"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0a/ea/13a1ef3c12d12662905801495283530251918b70d62d368f1d2e0272c70d/humanize-4.16.0.tar.gz", hash = "sha256:7dc2244a2f84a4bfb1d36c37bac80cd78e35cdc5c119206d87b018e1445f3a3f", size = 89515, upload-time = "2026-06-30T16:17:29.859Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/aa/0b7365d30fed43e7a3449aba1fe20a0a7174d9cf13e282af4e69ac825441/humanize-4.16.0-py3-none-any.whl", hash = "sha256:353eb2f34c09d098b2880eee8bef21832eae6d174f48c5762fff7e5fcb74d01d", size = 137209, upload-time = "2026-06-30T16:17:28.36Z" },
+]
+
[[package]]
name = "identify"
version = "2.6.12"
@@ -1366,6 +1776,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
+[[package]]
+name = "jmespath"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
+]
+
[[package]]
name = "joserfc"
version = "1.7.1"
@@ -1378,6 +1797,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/00/fa62404c3e347f946faa13aa21085205f9cc06ad17671e37f81a51662ae8/joserfc-1.7.1-py3-none-any.whl", hash = "sha256:b3e3d655612e2e1ef67b2600f2f420e12e537b020208fab1761fad647319c164", size = 70423, upload-time = "2026-06-08T07:21:32.001Z" },
]
+[[package]]
+name = "jsonpath-ng"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/32/58/250751940d75c8019659e15482d548a4aa3b6ce122c515102a4bfdac50e3/jsonpath_ng-1.8.0.tar.gz", hash = "sha256:54252968134b5e549ea5b872f1df1168bd7defe1a52fed5a358c194e1943ddc3", size = 74513, upload-time = "2026-02-24T14:42:06.182Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/99/33c7d78a3fb70d545fd5411ac67a651c81602cc09c9cf0df383733f068c5/jsonpath_ng-1.8.0-py3-none-any.whl", hash = "sha256:b8dde192f8af58d646fc031fac9c99fe4d00326afc4148f1f043c601a8cfe138", size = 67844, upload-time = "2026-02-28T00:53:19.637Z" },
+]
+
[[package]]
name = "jsonschema"
version = "4.26.0"
@@ -1669,6 +2097,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" },
]
+[[package]]
+name = "oauthlib"
+version = "3.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
+]
+
[[package]]
name = "ocotilloapi"
version = "1.2.0"
@@ -1788,6 +2225,13 @@ dev = [
{ name = "python-dotenv" },
{ name = "requests" },
]
+ingestion = [
+ { name = "dagster" },
+ { name = "dagster-cloud" },
+ { name = "dlt", extra = ["filesystem", "gs"] },
+ { name = "gcsfs" },
+ { name = "pyarrow" },
+]
[package.metadata]
requires-dist = [
@@ -1905,6 +2349,13 @@ dev = [
{ name = "python-dotenv", specifier = ">=1.1.1" },
{ name = "requests", specifier = ">=2.34.2" },
]
+ingestion = [
+ { name = "dagster", specifier = ">=1.13.18" },
+ { name = "dagster-cloud", specifier = ">=1.13.18" },
+ { name = "dlt", extras = ["filesystem", "gs"], specifier = ">=1.30.0" },
+ { name = "gcsfs", specifier = ">=2026.8.0" },
+ { name = "pyarrow", specifier = ">=25.0.1" },
+]
[[package]]
name = "openpyxl"
@@ -1958,6 +2409,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" },
]
+[[package]]
+name = "orjson"
+version = "3.12.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/cb/d7b78218a987eb8a8ce4eeae0286b1bb679333eb631ea0eeaf6371680bfc/orjson-3.12.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a36ec60f1796f9a3f13e3b98390295e17a1c7c10155b448d264098bf9ee5900", size = 223397, upload-time = "2026-08-14T16:12:44.003Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/4a/bc87c45e7ec639d35ebefd62618e01939531ac8e171426606a01bda05914/orjson-3.12.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ad0422b92d5195443a39f80c3bcf731cc2e00f153bd32063a47b73b057bd0f03", size = 123662, upload-time = "2026-08-14T16:12:45.433Z" },
+ { url = "https://files.pythonhosted.org/packages/94/ee/c9a4ff3f2dbedbbe9e635d0fa72c8866adede09b6335ef9644f53752f0d8/orjson-3.12.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5a0fdbc216388f653d3752ff310e710f59253bd4ed6a2bfb3f4f06b84714bbd8", size = 113374, upload-time = "2026-08-14T16:12:46.755Z" },
+ { url = "https://files.pythonhosted.org/packages/75/09/3f330a026a796c8b4c97a6f429652a5e912e7065039bf96ed25e42aa7b25/orjson-3.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2eb5c56e534127b2b8fa38d2363c8b1b8190367ee0d1d16c041517d880843b94", size = 130029, upload-time = "2026-08-14T16:12:48.06Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/40/094cc53126a3d22f76cdf83b6ea67338bed01d774037621a785aa8e6e5ea/orjson-3.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:784106539f4b9d4b930e0b4eb8d45168507dae001945e71b4675a367f1e5e806", size = 130528, upload-time = "2026-08-14T16:12:49.362Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/74/89bb236deb9565f99434b13052bb40ddfcce4adf3afbfa3132ee7e421468/orjson-3.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c680706fc8396d95e7c4c1f9482563f552137aef91b57237a3ad5aaf64629df", size = 131075, upload-time = "2026-08-14T16:12:50.692Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ac/1176360d762c01b5bd34acd56fc098e936c491363d8b6b397ad4aa475547/orjson-3.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83445adc40cba26d6d621185a45128ce455b766af368cad2ab64b970603a7978", size = 135321, upload-time = "2026-08-14T16:12:52.114Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/02/bbd881c8b9276d50b998de38b4e97de8ace1aac940b0ee545aedbf65ed00/orjson-3.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:644d005bc82f917337a95ce270c9f6f92f9834c2bed7b1477572f8db00784222", size = 127472, upload-time = "2026-08-14T16:12:53.517Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/02/a0934d7503e6dcbedd6afac3e7f3f8597fd09389949ad94d0f7540e9dbca/orjson-3.12.0-cp313-cp313-win32.whl", hash = "sha256:d8e78d3d93705e3d27cc17cdb209e44d7a8ea203010cac6ce9c7ffc1ae1996f1", size = 128000, upload-time = "2026-08-14T16:12:55.14Z" },
+ { url = "https://files.pythonhosted.org/packages/52/87/69f98f8d40faff103a965a5fbb83f08241b01beaf92badb5413fbc9358cc/orjson-3.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b85931be5b6763c31283805c9bdaae1ca03ad9f6f12a15f1cbf6745b907932c2", size = 121841, upload-time = "2026-08-14T16:12:56.507Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/07/b83046a4e3cadcc0987d0f160696107c4af706a619b56e4ad01940cadadf/orjson-3.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a31348d7dfa64cd9c78bd1f510ff44c48fe64d71094e6b90e364dba3b55949e", size = 126765, upload-time = "2026-08-14T16:12:57.806Z" },
+ { url = "https://files.pythonhosted.org/packages/12/9d/3931253e6f3148abf2cbe14830367042a4806b362ea520df2303db188fb9/orjson-3.12.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9e6fee342a48760e854d743e7a81534d8e2925a6f46e09f750cf56b50fd1de5d", size = 223391, upload-time = "2026-08-14T16:12:59.184Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/0e/b4a4f1e305367245877b967a0bad70fcf001d77c54ac4339a120b66fdae4/orjson-3.12.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:8c3bb86dd10f39b3fbf434b7d5dc7cac77d6fc8ac572ae30a10731ede2c4b647", size = 123659, upload-time = "2026-08-14T16:13:00.548Z" },
+ { url = "https://files.pythonhosted.org/packages/96/f3/6782c6fa85e2702bc66be183c3b421486167dcf266ee4dc1403fe3824870/orjson-3.12.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2bb3ce43203936072dd8b4917b01d3aecfc02329bfb42510cb7cfb24708adc9c", size = 113337, upload-time = "2026-08-14T16:13:02.009Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/79/b32ab64bacda9d0fa4942ef483bd03cabf0eaf2be819ca9fb7ff610c559d/orjson-3.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6a2a79c89984dc719817d388c8709e0efc2a2795a934eaa746b4882eb6045adc", size = 130112, upload-time = "2026-08-14T16:13:03.404Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/49/6e6142999ca01509219be5e5a9c338a3e5ea011f63e91ff473fbbf3734ed/orjson-3.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f06dd838d1e07d9b1de0932ec0485ec92c4d5f5d1ad4817a656268c3e88be1e1", size = 130520, upload-time = "2026-08-14T16:13:04.798Z" },
+ { url = "https://files.pythonhosted.org/packages/49/d0/3745af0a4cc9867784f29722929cec4d10bd1c877cd754b01ba6d96eb21a/orjson-3.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6b11be792c3d2c6a4be2af4ebf97a68d0bf5f580aca6e86a418a354f6cc846a", size = 131053, upload-time = "2026-08-14T16:13:06.14Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/f4/6fe5a22fa478fffb190e65c338c84df5c311ef597b363150a17cc57063c0/orjson-3.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:477ecaf6b9f88f873341b91fcc736119ca81b5e002a9f7f308ff5b4f2ce2a70e", size = 135321, upload-time = "2026-08-14T16:13:07.544Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/41/b1b0ec30289646a81a76e2dbaae2686b96fcccb7cb0323dc1dd78cbc7875/orjson-3.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3c0683136acdc29afdf88a5bc2f7d3d0e34087788d1d63c0144b805a87a196f", size = 127485, upload-time = "2026-08-14T16:13:08.88Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/2b/277404bdcc21c93b112b963655b76443ebfe828f8a3ff1de7d90f8850eb3/orjson-3.12.0-cp314-cp314-win32.whl", hash = "sha256:d39f3f5c3927e2dc0913fe5bbc1a2f6b1b9d1bba1de6358340d0ad0d0c00ca92", size = 128048, upload-time = "2026-08-14T16:13:10.305Z" },
+ { url = "https://files.pythonhosted.org/packages/41/2b/395b36fa2b4ce7af70b651d715e88f80d884b2c2b14a6b53e84d554fb5f0/orjson-3.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:0b1ac5bf6609b2716c7954011c5fef6254922df029f45d032ee4ebf5d363cbed", size = 121858, upload-time = "2026-08-14T16:13:11.634Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/a3/833e895ff452859eebe75093d26691fe9108f1a7a6a08435d7a5780ea652/orjson-3.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:50fae885cb073eac7556353ff3df93312b0d5137b0a5056b2bb63f97ed9a93c7", size = 126749, upload-time = "2026-08-14T16:13:13.117Z" },
+ { url = "https://files.pythonhosted.org/packages/58/64/99c8947ece10c17176af9aae85c4948f1d109da77440ec14d87239efaf73/orjson-3.12.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:01efac2074fffb4cb1ea3fab7861e9d0f2a26913854a972f5ac760525dbdaf6e", size = 223398, upload-time = "2026-08-14T16:13:14.694Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/30/cf983fe09f2731420fda097a9f7ef4343f47fa216c228961ad8f6da44f3d/orjson-3.12.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:ed4ca42bd55955aa34deedcfdfd0e0c31abf51143aae158ae2bc3520b626e517", size = 123655, upload-time = "2026-08-14T16:13:16.221Z" },
+ { url = "https://files.pythonhosted.org/packages/11/50/9cb8ae73fa4749dbbc20f617004213b5ff01c20aaeec34c3f31124f2c1d8/orjson-3.12.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:40f92192227505acca4e2533ce565f8e6b9535f7d0d09b0968452f18b7376b38", size = 130515, upload-time = "2026-08-14T16:13:17.601Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/0a/adb6ce1a5b5fbf9cb1790f9961bb668a0dd5429aadaf6cee044724681795/orjson-3.12.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:33efefcf5d88eaf400b47e2eba02f91f319bb9951be61ca500b7d536d3f2079d", size = 113327, upload-time = "2026-08-14T16:13:18.927Z" },
+ { url = "https://files.pythonhosted.org/packages/51/5c/d17f61581d8dbdde7048f87a330fa24915edec38db4d72b381fec14fbb56/orjson-3.12.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:8e386b0bc0ddd7cd2056f884b5a0af33592bd01ac66a7ca4b42a65a7e7774a13", size = 130105, upload-time = "2026-08-14T16:13:20.317Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/b7/938befcf33bee4704a92ecec6a2731224c539d939bf9429fd39396d28931/orjson-3.12.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:58c58e1de0006ffb580368d6793c36c7b0b021db066479cf281bf5061e732328", size = 131049, upload-time = "2026-08-14T16:13:21.719Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/15/cfa2021d64d5aa8bb5c9f604ef375e00ec8b657651b5dd650b1b7ad13df1/orjson-3.12.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:08231552159be266a7269555bd9f7c016aee7d9ad6dab06eb58796c5ccb7101c", size = 135320, upload-time = "2026-08-14T16:13:23.415Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/50/3e75dfe357c1e8f9e287c7a5740260ef15bd23a5299eae8d0835dcad5375/orjson-3.12.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a15f9a891bce5f5cc5d210e3ad8614d4d1b489a56448c099d6d2a7168b2d954a", size = 127488, upload-time = "2026-08-14T16:13:24.791Z" },
+ { url = "https://files.pythonhosted.org/packages/11/a6/79aed402eb3ab284dc5b4791a7ad62c5875127de01b8e3f04bd92d551298/orjson-3.12.0-cp315-cp315-win32.whl", hash = "sha256:03091c8a64db4be38746597ceea68f33c238e27acd9bfe99fb59420224ae7a55", size = 128048, upload-time = "2026-08-14T16:13:26.217Z" },
+ { url = "https://files.pythonhosted.org/packages/64/f7/2723e264aab7248c1ed6ecaad8e5d0cb866c0cffde75442102ffa7491aba/orjson-3.12.0-cp315-cp315-win_amd64.whl", hash = "sha256:2b7bcefb9f40fa242fa6b06377232c048e655747790829609168c01162f60578", size = 121860, upload-time = "2026-08-14T16:13:27.577Z" },
+ { url = "https://files.pythonhosted.org/packages/82/56/630c9113ec8996778f1f0304b364b091b9a9db5fef5fdc17cca622f5ea24/orjson-3.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc", size = 126754, upload-time = "2026-08-14T16:13:28.962Z" },
+]
+
[[package]]
name = "packaging"
version = "26.3"
@@ -2029,6 +2521,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" },
]
+[[package]]
+name = "pathlib-abc"
+version = "0.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/cb/448649d7f25d228bf0be3a04590ab7afa77f15e056f8fa976ed05ec9a78f/pathlib_abc-0.5.2.tar.gz", hash = "sha256:fcd56f147234645e2c59c7ae22808b34c364bb231f685ddd9f96885aed78a94c", size = 33342, upload-time = "2025-10-10T18:37:20.524Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b1/29/c028a0731e202035f0e2e0bfbf1a3e46ad6c628cbb17f6f1cc9eea5d9ff1/pathlib_abc-0.5.2-py3-none-any.whl", hash = "sha256:4c9d94cf1b23af417ce7c0417b43333b06a106c01000b286c99de230d95eefbb", size = 19070, upload-time = "2025-10-10T18:37:19.437Z" },
+]
+
[[package]]
name = "pathspec"
version = "1.0.4"
@@ -2038,6 +2539,57 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
+[[package]]
+name = "pathvalidate"
+version = "3.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" },
+]
+
+[[package]]
+name = "pendulum"
+version = "3.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+ { name = "tzdata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cb/72/9a51afa0a822b09e286c4cb827ed7b00bc818dac7bd11a5f161e493a217d/pendulum-3.2.0.tar.gz", hash = "sha256:e80feda2d10fa3ff8b1526715f7d33dcb7e08494b3088f2c8a3ac92d4a4331ce", size = 86912, upload-time = "2026-01-30T11:22:24.093Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/8c/400c8b8dbd7524424f3d9902ded64741e82e5e321d1aabbd68ade89e71cf/pendulum-3.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:addb0512f919fe5b70c8ee534ee71c775630d3efe567ea5763d92acff857cfc3", size = 337820, upload-time = "2026-01-30T11:21:24.305Z" },
+ { url = "https://files.pythonhosted.org/packages/59/38/7c16f26cc55d9206d71da294ce6857d0da381e26bc9e0c2a069424c2b173/pendulum-3.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3aaa50342dc174acebdc21089315012e63789353957b39ac83cac9f9fc8d1075", size = 327551, upload-time = "2026-01-30T11:21:25.747Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/cd/f36ec5d56d55104232380fdbf84ff53cc05607574af3cbdc8a43991ac8a7/pendulum-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:927e9c9ab52ff68e71b76dd410e5f1cd78f5ea6e7f0a9f5eb549aea16a4d5354", size = 339894, upload-time = "2026-01-30T11:21:27.229Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/4e/b9a1e546519c3a92d5bc17787cea925e06a20def2ae344fa136d2fc40338/pendulum-3.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:249d18f5543c9f43aba3bd77b34864ec8cf6f64edbead405f442e23c94fce63d", size = 373766, upload-time = "2026-01-30T11:21:28.642Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/a6/6471ab87ae2260594501f071586a765fc894817043b7d2d4b04e2eff4f31/pendulum-3.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c644cc15eec5fb02291f0f193195156780fd5a0affd7a349592403826d1a35e", size = 379837, upload-time = "2026-01-30T11:21:30.637Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/79/0ba0c14e862388f7b822626e6e989163c23bebe7f96de5ec4b207cbe7c3d/pendulum-3.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:063ab61af953bb56ad5bc8e131fd0431c915ed766d90ccecd7549c8090b51004", size = 348904, upload-time = "2026-01-30T11:21:32.436Z" },
+ { url = "https://files.pythonhosted.org/packages/17/34/df922c7c0b12719589d4954bfa5bdca9e02bcde220f5c5c1838a87118960/pendulum-3.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:26a3ae26c9dd70a4256f1c2f51addc43641813574c0db6ce5664f9861cd93621", size = 517173, upload-time = "2026-01-30T11:21:34.428Z" },
+ { url = "https://files.pythonhosted.org/packages/87/ec/3b9e061eeee97b72a47c1434ee03f6d85f0284d9285d92b12b0fff2d19ac/pendulum-3.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:2b10d91dc00f424444a42f47c69e6b3bfd79376f330179dc06bc342184b35f9a", size = 561744, upload-time = "2026-01-30T11:21:35.861Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/7e/f12fdb6070b7975c1fcfa5685dbe4ab73c788878a71f4d1d7e3c87979e37/pendulum-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:63070ff03e30a57b16c8e793ee27da8dac4123c1d6e0cf74c460ce9ee8a64aa4", size = 258746, upload-time = "2026-01-30T11:21:37.782Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/b8/5abd872056357f069ae34a9b24a75ac58e79092d16201d779a8dd31386bb/pendulum-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:c8dde63e2796b62070a49ce813ce200aba9186130307f04ec78affcf6c2e8122", size = 253028, upload-time = "2026-01-30T11:21:39.381Z" },
+ { url = "https://files.pythonhosted.org/packages/82/99/5b9cc823862450910bcb2c7cdc6884c0939b268639146d30e4a4f55eb1f1/pendulum-3.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c17ac069e88c5a1e930a5ae0ef17357a14b9cc5a28abadda74eaa8106d241c8e", size = 338281, upload-time = "2026-01-30T11:21:40.812Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/3a/64a35260f6ac36c0ad50eeb5f1a465b98b0d7603f79a5c2077c41326d639/pendulum-3.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e1fbb540edecb21f8244aebfb05a1f2333ddc6c7819378c099d4a61cc91ae93c", size = 328030, upload-time = "2026-01-30T11:21:42.778Z" },
+ { url = "https://files.pythonhosted.org/packages/da/6b/1140e09310035a2afb05bb90a2b8fbda9d3222e03b92de9533123afe6b65/pendulum-3.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8c67fb9a1fe8fc1adae2cc01b0c292b268c12475b4609ff4aed71c9dd367b4d", size = 340206, upload-time = "2026-01-30T11:21:44.148Z" },
+ { url = "https://files.pythonhosted.org/packages/52/4a/a493de56cbc24a64b21ac6ba98513a9ec5c67daa3dba325e39a8e53f30d8/pendulum-3.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:baa9a66c980defda6cfe1275103a94b22e90d83ebd7a84cc961cee6cbd25a244", size = 373976, upload-time = "2026-01-30T11:21:45.56Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/4c/f083c4fd1a161d4ab218680cc906338c541497b3098373f2241f58c429cb/pendulum-3.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef8f783fa7a14973b0596d8af2a5b2d90858a55030e9b4c6885eb4284b88314f", size = 380075, upload-time = "2026-01-30T11:21:46.959Z" },
+ { url = "https://files.pythonhosted.org/packages/57/b6/333a0fcb33bf15eb879a46a11ce6300c1698a141e689665fe430783ff8d6/pendulum-3.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7d2e9bfb065727d8676e7ada3793b47a24349500a5e9637404355e482c822be", size = 349026, upload-time = "2026-01-30T11:21:48.271Z" },
+ { url = "https://files.pythonhosted.org/packages/43/1a/dfb526ec0cba1e7cd6a5e4f4dd64a6ada7428d1449c54b15f7b295f6e122/pendulum-3.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:55d7ba6bb74171c3ee409bf30076ee3a259a3c2bb147ac87ebb76aaa3cf5d3a2", size = 517395, upload-time = "2026-01-30T11:21:49.643Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/37/b4f2b5f1200351c4869b8b46ad5c21019e3dbe0417f5867ae969fad7b5fe/pendulum-3.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:a50d8cf42f06d3d8c3f8bb2a7ac47fa93b5145e69de6a7209be6a47afdd9cf76", size = 561926, upload-time = "2026-01-30T11:21:51.698Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/9e/567376582da58f5fe8e4f579db2bcfbf243cf619a5825bdf1023ad1436b3/pendulum-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e5bbb92b155cd5018b3cf70ee49ed3b9c94398caaaa7ed97fe41e5bb5a968418", size = 258817, upload-time = "2026-01-30T11:21:53.074Z" },
+ { url = "https://files.pythonhosted.org/packages/95/67/dfffd7eb50d67fa821cd4d92cf71575ead6162930202bc40dfcedf78c38c/pendulum-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:d53134418e04335c3029a32e9341cccc9b085a28744fb5ee4e6a8f5039363b1a", size = 253292, upload-time = "2026-01-30T11:21:54.484Z" },
+ { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" },
+]
+
+[[package]]
+name = "pex"
+version = "2.59.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/91/35/6bfd6677428fa0c3aec43d05760854ec9b0a054f0e456665903b1a08098f/pex-2.59.5.tar.gz", hash = "sha256:3bcad71f7dd2df47f9b4dd0dea5d837fe4d9d2792b79fd34726c00bfd5f05923", size = 5136890, upload-time = "2025-10-09T04:19:06.255Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/eb/80e9012146f6dbba4e1b3dae8b45c771b459fc1615e17e80b1f322794d7c/pex-2.59.5-py2.py3-none-any.whl", hash = "sha256:b9dba4f05b6be08da89b0c54f4bc75ed01c32dfde8ee06f87e82b3a6c69ee388", size = 3873314, upload-time = "2025-10-09T04:19:04.123Z" },
+]
+
[[package]]
name = "pg8000"
version = "1.31.5"
@@ -2156,6 +2708,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" },
]
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.53"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" },
+]
+
[[package]]
name = "propcache"
version = "0.5.2"
@@ -2318,6 +2882,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" },
]
+[[package]]
+name = "pyarrow"
+version = "25.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size = 35855328, upload-time = "2026-08-10T12:38:45.489Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size = 37622415, upload-time = "2026-08-10T12:38:51.107Z" },
+ { url = "https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size = 46813813, upload-time = "2026-08-10T12:38:57.773Z" },
+ { url = "https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size = 50104452, upload-time = "2026-08-10T12:39:04.579Z" },
+ { url = "https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size = 49951343, upload-time = "2026-08-10T12:39:11.8Z" },
+ { url = "https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size = 53144784, upload-time = "2026-08-10T12:39:20.503Z" },
+ { url = "https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size = 27870159, upload-time = "2026-08-10T12:39:26.161Z" },
+ { url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" },
+ { url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" },
+ { url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" },
+ { url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" },
+ { url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" },
+ { url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" },
+ { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" },
+]
+
[[package]]
name = "pyasn1"
version = "0.6.4"
@@ -2517,6 +3110,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
+[package.optional-dependencies]
+crypto = [
+ { name = "cryptography" },
+]
+
[[package]]
name = "pymssql"
version = "2.3.13"
@@ -2595,6 +3193,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/73/a7141a1a0559bf1a7aa42a11c879ceb19f02f5c6c371c6d57fd86cefd4d1/pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4", size = 6391844, upload-time = "2025-08-14T12:05:40.745Z" },
]
+[[package]]
+name = "pyreadline3"
+version = "3.5.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" },
+]
+
[[package]]
name = "pyshp"
version = "2.3.1"
@@ -2711,6 +3318,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" },
]
+[[package]]
+name = "pywin32"
+version = "312"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" },
+ { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
+ { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" },
+ { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
+]
+
[[package]]
name = "pyyaml"
version = "6.0.2"
@@ -2728,6 +3351,18 @@ 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 = "questionary"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "prompt-toolkit" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
+]
+
[[package]]
name = "rasterio"
version = "1.5.0"
@@ -2869,6 +3504,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
+[[package]]
+name = "requests-oauthlib"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "oauthlib" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
+]
+
+[[package]]
+name = "requirements-parser"
+version = "0.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/89/1a/5f3c22d38bf1d87d1f4a961489d9eba35c4370a21395562d94410cdd0e73/requirements_parser-0.13.1.tar.gz", hash = "sha256:78811383b2089b6c5197a1431bc2c12ff950245edca39a23eea3460782038dd3", size = 22783, upload-time = "2026-06-18T07:52:25.291Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bb/f9/15b44d5e4401b0013bbcefe3c09d7bfddcce28cc3d41b1d3077bcedf5b1f/requirements_parser-0.13.1-py3-none-any.whl", hash = "sha256:6e385663eb32589d16e5b22bb6e5251a57908e73803ffff438b53cd6ea2056e0", size = 14926, upload-time = "2026-06-18T07:52:24.171Z" },
+]
+
[[package]]
name = "rich"
version = "14.3.2"
@@ -2882,6 +3542,18 @@ 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 = "rich-argparse"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "rich" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6a/e5/1064c43203a357d668cd42435f7a15fe6af51512d85b2104fecb937aa861/rich_argparse-1.8.0.tar.gz", hash = "sha256:679df3d832fa94ad6e4bdb07ded088cd7ea2dddc58ae9b2b46346a40b06cbc0c", size = 38940, upload-time = "2026-05-01T15:18:43.604Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/35/1cceccc5fcb50fa2ed53e2aa278cd032f3902682a73e763fb1ac3be8e6fa/rich_argparse-1.8.0-py3-none-any.whl", hash = "sha256:d2a3ce7854654e2253c578763ab0a32f05016f23a55fadba7b9a91b6c0e92142", size = 25616, upload-time = "2026-05-01T15:18:42.395Z" },
+]
+
[[package]]
name = "rpds-py"
version = "0.30.0"
@@ -2960,6 +3632,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
+[[package]]
+name = "s3fs"
+version = "2026.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiobotocore" },
+ { name = "aiohttp" },
+ { name = "fsspec" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/12/60/69fc080b72a32971b2fb5acbc80802b0e876b606f6e27b1689caac4bb57b/s3fs-2026.7.0.tar.gz", hash = "sha256:76b062d1b2bc7bf4bcd9e7d8f1eb2b5dd9d5cee96ce888664c4ddb5f563146bf", size = 87595, upload-time = "2026-07-28T17:14:10.595Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/cc/bcde19a37952ecc58e7d9d67ecaa048e1e21b17d014ce0863a6a6101e606/s3fs-2026.7.0-py3-none-any.whl", hash = "sha256:64edf3c01ebffab1eec38ff9c09eefbf86a3db14c87d248f795da0e7b801d698", size = 32659, upload-time = "2026-07-28T17:14:09.497Z" },
+]
+
[[package]]
name = "scramp"
version = "1.4.17"
@@ -2972,6 +3658,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/99/0e372781210cd36b2f2727e5be3ea93066edad7edd6fa2dfdec3b3e28845/scramp-1.4.17-py3-none-any.whl", hash = "sha256:a4e3fd2e8169461a28a13777a166d3da94274454f0714a7d3023fee124474ac8", size = 16131, upload-time = "2026-08-07T17:19:39.591Z" },
]
+[[package]]
+name = "semver"
+version = "3.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" },
+]
+
[[package]]
name = "sentry-sdk"
version = "2.68.0"
@@ -2990,6 +3685,15 @@ fastapi = [
{ name = "fastapi" },
]
+[[package]]
+name = "setuptools"
+version = "84.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" },
+]
+
[[package]]
name = "shapely"
version = "2.1.2"
@@ -3042,6 +3746,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
+[[package]]
+name = "simplejson"
+version = "4.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0e/2a/54837395a3487c725669428d513293612a48d82b95a0642c936932e5d898/simplejson-4.1.1.tar.gz", hash = "sha256:c08eb9f7a90f77ae470e19a07472e9a79ebc0d1c2315d86a72767665bd5ba79f", size = 118860, upload-time = "2026-04-24T19:24:59.819Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/a9/47b445eeb559c9593453a0648e0fd6d08e8adff64dd5e5ced66726da8a09/simplejson-4.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dff52fc7af272e84fc21cc5a06c927c823ca6ae00af14f3b0d7707b42775ed98", size = 113160, upload-time = "2026-04-24T19:23:26.033Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/65/cb72db31523c164dea5dc55b02dad065a40c478856bc7534b279d2b51906/simplejson-4.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:971aed0647ad6e840a3943bec812fcda5f2d26a5497a4981d1fb49aa4f9a396c", size = 91521, upload-time = "2026-04-24T19:23:27.572Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e5/54cb7c50ad5fdc1e0a86b7df4b135c2cbd5c4623605aa94466659098e8da/simplejson-4.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:249e2e220aa6d9b9d936bde84eb7bf79d5b6c5a8273c6e411f8b1635a9073f2d", size = 91407, upload-time = "2026-04-24T19:23:28.991Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2e/21a3ede87f0bf82d6c7bcb90480d50a6490eb974c6ab20881188e440957c/simplejson-4.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e5cdd6a5d52299f345c15ab5678cc4249e24f383f361d986afbc3c7072a6b6b", size = 192451, upload-time = "2026-04-24T19:23:30.56Z" },
+ { url = "https://files.pythonhosted.org/packages/59/df/9903edd3102bf0b5984edfcb90c88612330996efa3b4fbf8a971d6e17839/simplejson-4.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642cec364e0676e2d5a73fa4d31d0c7c55886997caa2fde24e8292ca44d32728", size = 189015, upload-time = "2026-04-24T19:23:32.647Z" },
+ { url = "https://files.pythonhosted.org/packages/98/cd/33230927a780e1398b857e3944abb914556994d252b1d765ae40d112cb25/simplejson-4.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:76fe296ca1df23d290033f10aaacf534fd1b3e3007e7f9ff8aa68b21413aaa78", size = 196658, upload-time = "2026-04-24T19:23:34.563Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/84/2c5a7444eb53e9a86d3738299bffddd9f53aeed799ded2f45368221fdb19/simplejson-4.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f0ad25b7dc4e0fb23858355819f2e994f1a5badcdcde8737eac7921c2f1ed2a", size = 185967, upload-time = "2026-04-24T19:23:36.191Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/68/454378e06d059cd412a7ed5d87fb6d29fd5b60f13a4d89fc1f764ff434df/simplejson-4.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a59ebd0533f03fd06ff0c42ba0f02d93cbcdd7944922bf3b93911327a95b901f", size = 193940, upload-time = "2026-04-24T19:23:38.151Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/d5/a15bf915f623a2c5a079d6e3be8256fdb8ef06f110669493a09b9d6933e0/simplejson-4.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bccbf4419676b517939852e5aeff2af6aee4dc046881c67a1581fa6f1cb01abd", size = 189795, upload-time = "2026-04-24T19:23:40.139Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/c9/37212ae7dc4b607f0978c408e8633f05c810884e054c33113184c6c2c8a2/simplejson-4.1.1-cp313-cp313-win32.whl", hash = "sha256:6c845363eb5fd166fb7c72243da38f4fcfde666ede7fdf2cc6fd7762894626f7", size = 88773, upload-time = "2026-04-24T19:23:41.754Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/a5/c7a0a47883a9015b54c9d8a4b62f2aba17bd4335b1787b9b8a0fc2fa6d52/simplejson-4.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:104d8324c34f25b4b90800bc5fa363780cbc3d8496aef061cba7ce1af9162270", size = 90888, upload-time = "2026-04-24T19:23:43.11Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/18/4a118a6a92eb33bb08c8e2fe7ec85cb96f0673491bb2b829930831ee4fbe/simplejson-4.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ed7473602b6625de793b6acba49aa949f144a475f538792067e4cf2fda2071f5", size = 110492, upload-time = "2026-04-24T19:23:44.957Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f4/84d160e9fa8cada1e0a9381cae4fa81eecd573577a5b34366d8ced59bdf7/simplejson-4.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:225c9caa324c5b554d009fb9cac22aee7711e71bd96f487938c659af467e828e", size = 90152, upload-time = "2026-04-24T19:23:46.355Z" },
+ { url = "https://files.pythonhosted.org/packages/68/31/9a5432c433a7671107182cdc9a20ea78a70f99c4e5334aa54b6d4d0d79ed/simplejson-4.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:95407269340c7f22f09776ea7b717a52cf56cfcf119b5e45f66faa4a26445bea", size = 90115, upload-time = "2026-04-24T19:23:47.743Z" },
+ { url = "https://files.pythonhosted.org/packages/78/91/3635cdb13318cb0a328abaa69e2b91251caad39d6779aa308098f341f6cb/simplejson-4.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3851658d642c1184d2023f0e6c9ce44a21eb1629e74e7c84ef956b128841fe12", size = 184036, upload-time = "2026-04-24T19:23:49.472Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/ba/149b6ec5393f6849d98c59cadba888b710a8ef4b805ab91e11a566960d40/simplejson-4.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95a3bb0f78e85f4937f99092239f2011ce06f0f2d803df5c299cc05abbeae008", size = 180543, upload-time = "2026-04-24T19:23:51.023Z" },
+ { url = "https://files.pythonhosted.org/packages/df/7c/a5d968d0b527a748b667e62bea94309ccbcb1e2b108e8f0cf8547efaa12b/simplejson-4.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbfdaa7c0603f75b7b14b211b7f2be44696d4e26833ad2d91d5c87bf5fb9a920", size = 188725, upload-time = "2026-04-24T19:23:52.995Z" },
+ { url = "https://files.pythonhosted.org/packages/db/e3/6a8d11181d587ef00e2db9112357e6832111e56dd56b01b5c11758a1965d/simplejson-4.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e3c584071dced8c21b4689f0254303521daeb9b5bc1f4289755d71fa3cb0d3", size = 177492, upload-time = "2026-04-24T19:23:54.581Z" },
+ { url = "https://files.pythonhosted.org/packages/67/e3/8b0eb8b06e8198cfbd1270487da163d0093df05cc4f557350cd65e2f7e79/simplejson-4.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:036a27bd0469b9d79557cbddb392969f876cd7f278cfbd0fba81534927a06575", size = 185281, upload-time = "2026-04-24T19:23:56.13Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/5f/64990f07ec9e2cb1a814c674e2e21b5693207f74ac70eb72151b847ea4e6/simplejson-4.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b70bfd2f67f3351baba08aa3ae9233c83f21fd95ae5e6b3d0ecb8c647929112f", size = 181848, upload-time = "2026-04-24T19:23:57.92Z" },
+ { url = "https://files.pythonhosted.org/packages/61/a5/bbc1bc0447f339f79f99ab8c37f7f037cb2f1f93af75d6a4d553096bb0c3/simplejson-4.1.1-cp314-cp314-win32.whl", hash = "sha256:37233c72ce88d06acb92747347742b3c07871eba6789f060c179c9302dde8efe", size = 88761, upload-time = "2026-04-24T19:23:59.397Z" },
+ { url = "https://files.pythonhosted.org/packages/18/72/ec1b5cbdcb140c132e6c7bdf99bd73e4f675439e77126c88f472fcffa09c/simplejson-4.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:cc0442dea71cd9cbf30a0b8b9929ab5aa6c02c0443a3d977351e6ec5bada4388", size = 91018, upload-time = "2026-04-24T19:24:00.85Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/97/4fa437f68ff72219bac3bf3d050de9c6265691f3a170e16954bd69d7cddd/simplejson-4.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:c996a4d38290c515af347740659ce095b425449c164a5c9fa3977caa6eff5dbe", size = 113919, upload-time = "2026-04-24T19:24:02.287Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/83/59de041d09eb4a9577f7015d7263c32095dfb7fde49717dff62145d89809/simplejson-4.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c65c763fb20d7ca113c1c14dce2fc04a0fc3a57aceff533d6fdac707c7bffb40", size = 91904, upload-time = "2026-04-24T19:24:03.812Z" },
+ { url = "https://files.pythonhosted.org/packages/03/8e/46bb345d540f6eb31427d984a4e518cdb182d0621814fee4fee045e8815b/simplejson-4.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0da5c9f57206ee7ef280ff7f1d924937b0a64f9a271a5ef371a2ecdbebba7421", size = 91752, upload-time = "2026-04-24T19:24:05.622Z" },
+ { url = "https://files.pythonhosted.org/packages/83/e2/1b2ce97f068835eb3d253c116a4df7a3f436b7bf2fb5ff1ba29287e8b0ec/simplejson-4.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ea3426e786425d10e9e82f8a6eda74a7d6eb10d99165ac3d0d3bbcb65c0ea343", size = 214021, upload-time = "2026-04-24T19:24:07.447Z" },
+ { url = "https://files.pythonhosted.org/packages/48/70/d93e556df6a0786298644a7c08304fcbeddc248325f23f38acbebeb21165/simplejson-4.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d75cea7a1025edd7e439b2966b3d977c45b5b899e2adaf422811b3ac702ed9fb", size = 213530, upload-time = "2026-04-24T19:24:09.289Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/a5/c93bf305b9f00d7259e09e713d60e75bd0f7f53da970f716ab90491770e7/simplejson-4.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63c2ada8e58f266491f19eed2eeeb7c25c6141e52f8f9e820f6bb94156cf8dbc", size = 218282, upload-time = "2026-04-24T19:24:10.991Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/20/a9b5d2e27ec44b069ee251bd55544fc76929a067107b1050001566ba86f3/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d1fffb56305c5b475ee746cf9e04f97423ba5aaacd292dc1255bd75b1d3b124b", size = 209249, upload-time = "2026-04-24T19:24:12.662Z" },
+ { url = "https://files.pythonhosted.org/packages/97/e4/e06ee682ed5df67592181f5ecb062e35878967e27f5b6e087237d4548d95/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a6525ec733f43d0541206cffa64fd2aad5a7ae3eb76566aff49cd4db6382209a", size = 213963, upload-time = "2026-04-24T19:24:14.302Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/9f/1e160e4cd8cdbf062bf6a454cdf814dc7a48eb47e566fdb8f80ccb202605/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:861e393260508efa64d8805a8e49c416c3484907e3f146ce966c69552b49b9a3", size = 210474, upload-time = "2026-04-24T19:24:15.917Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/e6/cecd913df322df5bbe7ebb8ba39e0708e505a165553900da8a7761026d6f/simplejson-4.1.1-cp314-cp314t-win32.whl", hash = "sha256:d083b89d30948a751d3d97476c2ed91e4caaa24a1a1459bdbadb8876242c71fe", size = 91134, upload-time = "2026-04-24T19:24:17.635Z" },
+ { url = "https://files.pythonhosted.org/packages/97/73/f540dde99cc1d393bd062ab3b5735b777561a5d8f8a5f2e241164444d77a/simplejson-4.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4cbb299d0528ec0447fe366d8c9641860e28f997a62730690fef905f1f41046e", size = 94467, upload-time = "2026-04-24T19:24:19.109Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/6a/8b74c52ffd33dbbde00fe7251fee6a0acdc8cea33f7a43805aed258fb79b/simplejson-4.1.1-py3-none-any.whl", hash = "sha256:2ce92b3748f02423e26d2bfb636fb9d7a8f67c8f5854dcae69d350d123b2eee2", size = 69195, upload-time = "2026-04-24T19:24:57.962Z" },
+]
+
[[package]]
name = "six"
version = "1.17.0"
@@ -3051,6 +3797,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
+[[package]]
+name = "smmap"
+version = "5.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" },
+]
+
[[package]]
name = "sniffio"
version = "1.3.1"
@@ -3125,6 +3880,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/25/7400c18c3ee97914cc99c90007795c00a4ec5b60c853b49db7ba24d11179/sqlalchemy_utils-0.42.1-py3-none-any.whl", hash = "sha256:243cfe1b3a1dae3c74118ae633f1d1e0ed8c787387bc33e556e37c990594ac80", size = 91761, upload-time = "2025-12-13T03:14:15.014Z" },
]
+[[package]]
+name = "sqlglot"
+version = "30.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/56/d4/da49abcc81beebbb25f29ddf87f2980c63c949569d7f2da40c06d95fa415/sqlglot-30.17.0.tar.gz", hash = "sha256:2d6b8def93304fa300f4d20f48e3909e7f436fda56ca1fafd8975f6c561ef62c", size = 5999019, upload-time = "2026-08-12T19:36:50.587Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/bc/2a07cef49046e6cf5d1a8b1de553aaef8491b4170dbfe254c8687c764408/sqlglot-30.17.0-py3-none-any.whl", hash = "sha256:84435ac283a60173da31b5fd7d11a725037a1c3fd6ed1e21fb065de74ddb579f", size = 741795, upload-time = "2026-08-12T19:36:48.699Z" },
+]
+
[[package]]
name = "sqlparse"
version = "0.6.0"
@@ -3146,6 +3910,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
]
+[[package]]
+name = "structlog"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" },
+]
+
+[[package]]
+name = "tabulate"
+version = "0.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" },
+]
+
+[[package]]
+name = "tenacity"
+version = "9.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
+]
+
[[package]]
name = "tinydb"
version = "4.8.2"
@@ -3155,6 +3946,72 @@ 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 = "tomli"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
+]
+
+[[package]]
+name = "tomlkit"
+version = "0.15.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" },
+]
+
+[[package]]
+name = "toposort"
+version = "1.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/19/8e955d90985ecbd3b9adb2a759753a6840da2dff3c569d412b2c9217678b/toposort-1.10.tar.gz", hash = "sha256:bfbb479c53d0a696ea7402601f4e693c97b0367837c8898bc6471adfca37a6bd", size = 11132, upload-time = "2023-02-27T13:59:51.834Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f6/17/57b444fd314d5e1593350b9a31d000e7411ba8e17ce12dc7ad54ca76b810/toposort-1.10-py3-none-any.whl", hash = "sha256:cbdbc0d0bee4d2695ab2ceec97fe0679e9c10eab4b2a87a9372b929e70563a87", size = 8500, upload-time = "2023-02-25T20:07:06.538Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.70.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" },
+]
+
[[package]]
name = "typer"
version = "0.27.1"
@@ -3221,6 +4078,19 @@ 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 = "universal-pathlib"
+version = "0.3.10"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "fsspec" },
+ { name = "pathlib-abc" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/6e/d997a70ee8f4c61f9a7e2f4f8af721cf072a3326848fc881b05187e52558/universal_pathlib-0.3.10.tar.gz", hash = "sha256:4487cbc90730a48cfb64f811d99e14b6faed6d738420cd5f93f59f48e6930bfb", size = 261110, upload-time = "2026-02-22T14:40:58.87Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/1a/5d9a402b39ec892d856bbdd9db502ff73ce28cdf4aff72eb1ce1d6843506/universal_pathlib-0.3.10-py3-none-any.whl", hash = "sha256:dfaf2fb35683d2eb1287a3ed7b215e4d6016aa6eaf339c607023d22f90821c66", size = 83528, upload-time = "2026-02-22T14:40:57.316Z" },
+]
+
[[package]]
name = "uritemplate"
version = "4.2.0"
@@ -3261,6 +4131,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/2b/ebd108734a8204c6b4b93c681c9a38c5273b3ccd5d129fee4ffc1d97772c/uvicorn-0.52.3-py3-none-any.whl", hash = "sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c", size = 79859, upload-time = "2026-08-13T16:50:01.323Z" },
]
+[[package]]
+name = "validators"
+version = "0.35.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/66/a435d9ae49850b2f071f7ebd8119dd4e84872b01630d6736761e6e7fd847/validators-0.35.0.tar.gz", hash = "sha256:992d6c48a4e77c81f1b4daba10d16c3a9bb0dbb79b3a19ea847ff0928e70497a", size = 73399, upload-time = "2025-05-01T05:42:06.7Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/6e/3e955517e22cbdd565f2f8b2e73d52528b14b8bcfdb04f62466b071de847/validators-0.35.0-py3-none-any.whl", hash = "sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd", size = 44712, upload-time = "2025-05-01T05:42:04.203Z" },
+]
+
[[package]]
name = "virtualenv"
version = "20.32.0"
@@ -3275,6 +4154,36 @@ 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 = "watchdog"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
+ { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
+ { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
+ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
+]
+
+[[package]]
+name = "wcwidth"
+version = "0.8.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
+]
+
[[package]]
name = "werkzeug"
version = "3.1.6"
@@ -3287,6 +4196,59 @@ 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 = "wrapt"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" },
+ { url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" },
+ { url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" },
+ { url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" },
+ { url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" },
+ { url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" },
+ { url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" },
+ { url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" },
+ { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" },
+ { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" },
+ { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" },
+ { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" },
+ { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" },
+ { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" },
+]
+
[[package]]
name = "yarl"
version = "1.24.5"
From 4fd5a5797eeb1b3e09baa313ae7db6d16d661ba7 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 11:59:42 -0700
Subject: [PATCH 062/151] docs(ingestion): record the Diver-HUB findings; the
blocker is cleared
Chase Martin traced the 500s to our own request, not a vendor outage. Readings
come from the private Diver-HUB API rather than the doubled-segment path the
draft assumed, and the endpoint returns 500 for an oversized span instead of
paginating -- a three-month window in Unix seconds is known to work.
Two consequences the draft got wrong. The source is not unauthenticated: login
returns a JWT good for one hour, so any run outliving that refreshes mid-run,
which every backfill will. And a fetch is always a sequence of bounded windows,
including on the daily incremental path when an entity's cursor has fallen
behind.
The exact window ceiling is still unmeasured; 2.1 now carries finding it.
Co-Authored-By: Claude Opus 5
---
.../sources/san_acacia/__init__.py | 14 +++++--
docs/automated-ingestion-pipeline-plan.md | 40 +++++++++++++------
2 files changed, 38 insertions(+), 16 deletions(-)
diff --git a/automated_ingestion/sources/san_acacia/__init__.py b/automated_ingestion/sources/san_acacia/__init__.py
index 17e2771f8..28d879b9d 100644
--- a/automated_ingestion/sources/san_acacia/__init__.py
+++ b/automated_ingestion/sources/san_acacia/__init__.py
@@ -16,9 +16,17 @@
"""
San Acacia Reach -- 33 Van Essen divers, one depth-to-groundwater series each.
-The pilot source. Unauthenticated, small, and already mapped, so it exercises
-the whole path end to end without authentication or pagination complicating the
-first build. Readings land on the **ground-surface** datum (Van Essen's ``gs``
+The pilot source: small and already mapped, so it exercises the whole path end
+to end without a large or unfamiliar dataset complicating the first build.
+
+Readings come from the private Diver-HUB API, which shapes the extraction in
+two ways. Requests carry a JWT good for one hour, so anything long-running
+refreshes mid-run rather than authenticating once at the start. And
+``DiverData/ByMonitoringPoint/{id}`` returns HTTP 500 when asked for too wide a
+span instead of paginating, so reads are always bounded windows in Unix
+seconds -- roughly three months is known to work.
+
+Readings land on the **ground-surface** datum (Van Essen's ``gs``
arrays, never ``vrd``), public but provisional, and always ``not reviewed`` --
the vendor's own approval flag records what the vendor approved, not a Bureau
review.
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index 7374b4e0c..05befd2a5 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -15,9 +15,9 @@ Stack: **Dagster+** code location → **dlt** extraction → **GCS** raw parquet
- **Public + provisional.** Visible from the first run, and marked provisional so no consumer mistakes an uncorrected diver series for a reviewed one. This matches what the retired FROST pipeline asserted for this source (`is_provisional: true`) — adopted deliberately here rather than inherited silently, which was the open question left in Aqueduct's mapping doc. It needs a schema change: `release_status` is one column, and its lexicon lists `public` and `provisional` as siblings, so visibility and maturity — two orthogonal axes — currently collide.
- **Vendor approval flag ≠ Ocotillo review status.** Van Essen's `approvedWaterLevels*` records what *the vendor* approved. Ocotillo's `review_status` is `approved` / `not reviewed`, and `TransducerObservationBlock.reviewer_id` FKs a Bureau `Contact` — so `approved` asserts a Bureau human reviewed it. Mapping one onto the other would manufacture provenance that doesn't exist. All San Acacia blocks land `not reviewed`; the vendor flag is preserved as a separate per-row attribute.
-**Watch:** one external blocker (Van Essen's readings endpoint 500s, vendor-escalated) and two schema changes — a unique constraint on `transducer_observation`, and a new field because `release_status` cannot hold "public" and "provisional" at once.
+**Watch:** two schema changes — a unique constraint on `transducer_observation`, and a new field because `release_status` cannot hold "public" and "provisional" at once. The vendor blocker cleared on 2026-08-18: the readings endpoint works, but only through the private Diver-HUB API, only with a 1-hour JWT, and only in bounded time windows.
-**Sequencing:** Task 1 gates everything. Tasks 2 and 3 run largely in parallel after it. Only two sub-tasks actually block on the vendor.
+**Sequencing:** Task 1 gates everything. Tasks 2 and 3 run largely in parallel after it. Nothing is vendor-blocked any more.
## All tasks
@@ -29,9 +29,9 @@ Stack: **Dagster+** code location → **dlt** extraction → **GCS** raw parquet
| 1.3 | GCS buckets + service account | `ocotillo-ingestion-{production,staging}`, date-partitioned layout | — |
| 1.4 | DB connectivity + least-privilege role | Cloud SQL connector from serverless; scoped Postgres role | 1.2 |
| **T2** | **Source extraction** | Van Essen API → GCS raw zone | T1 |
-| 2.1 | Confirm endpoint + finalize mapping | **Vendor-blocked.** Resolve 500s; confirm `ts`, units, fixtures | vendor |
+| 2.1 | Confirm endpoint + finalize mapping | **Unblocked.** Diver-HUB swagger, JWT login, measure the window ceiling | — |
| 2.2 | dlt resource: locations | 33 wells, `replace`, one call, no pagination | 1.3 |
-| 2.3 | dlt resource: readings, incremental | Per-point fetch, dlt cursor, `append`, per-entity failure isolation | 2.1 (live only) |
+| 2.3 | dlt resource: readings, incremental | Windowed per-point fetch, dlt cursor, `append`, token refresh, failure isolation | 2.1 |
| **T3** | **Domain mapping + load** | Van Essen records → Ocotillo Postgres | T1 |
| 3.1 | Domain layer | Pure functions: units, datum, timestamps, geometry, external keys | — |
| 3.2 | Bootstrap reference data | Reconcile 33 wells; seed parameter, sensor, deployments | 3.1 |
@@ -55,7 +55,7 @@ The Hydrograph Corrector UI exists and works (BDMS-1137 done), but has no automa
New top-level `automated_ingestion/` package in OcotilloAPI, deployed as its own Dagster+ code location in the existing `nmbgmr-data-services` org. dlt extracts the Van Essen API to a GCS raw zone; a `domain/` layer maps to the Ocotillo model; a loader writes to Ocotillo Postgres over a direct DB connection. Watermark and backfill mechanics come from Aqueduct.
-San Acacia first: unauthenticated, 33 wells, one DTW series each, and already mapped in `Aqueduct/docs/sources/san_acacia.md`. What it establishes — source registry, per-source dlt pipeline, adapter, backfill job factory — every later source inherits.
+San Acacia first: 33 wells, one DTW series each, and already mapped in `Aqueduct/docs/sources/san_acacia.md`. It authenticates with a short-lived JWT and must be read in bounded time windows — both cheap enough here to establish the pattern before a harder source needs it. What it establishes — source registry, per-source dlt pipeline, adapter, backfill job factory — every later source inherits.
**Ownership: OcotilloAPI.** Not a third Aqueduct source writing into Ocotillo. The loader writes over a direct database connection, which wants the `db/` SQLAlchemy models and `domain/` rules in-process rather than a duplicated schema in another repo. Aqueduct stays the FROST/SensorThings pipeline; this is Ocotillo's own. The two share code by porting (see below), not by importing.
@@ -94,9 +94,20 @@ San Acacia first: unauthenticated, 33 wells, one DTW series each, and already ma
- Series render in the Hydrograph Corrector.
- Domain mapping unit-tested with no database, per `ADR4.md`.
-### Blocker
+### Blocker — resolved 2026-08-18
-Van Essen `GET /api/api/monitoringPoint/{project}/{id}` — the only readings endpoint — returns **HTTP 500 for every ID tried**; escalated to the vendor. Everything except live-readings verification proceeds on recorded fixtures. Tracked in sub-task 2.1.
+The 500s were never a vendor outage. Two things were wrong on our side, both reported by Chase Martin:
+
+1. **Wrong API.** Readings come from the private Diver-HUB API — `GET https://diver-hub.com/private/api/v1/DiverData/ByMonitoringPoint/{id}` — not the doubled-segment `/api/api/monitoringPoint/{project}/{id}` path the earlier draft assumed. Swagger: `https://diver-hub.com/private/swagger/index.html`, which is now the authority over anything inferred from retired FROST data.
+2. **Window too large.** The endpoint 500s rather than paginating or erroring cleanly when asked for too much. A confirmed-good request is a ~3-month window in **Unix seconds**:
+
+ ```
+ https://diver-hub.com/private/api/v1/DiverData/ByMonitoringPoint/40?startTime=1767225600&endTime=1775001600
+ ```
+
+**Auth:** POST to the login endpoint with the credentials Ethan circulated; it returns a **JWT valid for one hour**. This overturns the "unauthenticated" assumption in the earlier draft and has two consequences: the token is a secret needing the same handling as the DB credentials, and any run outliving an hour — every backfill — must refresh mid-run rather than acquire once at start.
+
+**Still open:** the actual window ceiling. Three months works; the limit is unmeasured. Until it is, chunk conservatively and treat a 500 as "too much data" rather than a hard failure.
### Related
@@ -166,15 +177,17 @@ Land locations and readings untransformed in GCS as date-partitioned parquet. Ra
### 2.1 — Confirm the readings endpoint; finalize the source mapping
-Endpoint returns HTTP 500 for every ID; escalated to Van Essen. Some mapping details were inferred from retired FROST data, not the live API.
+**Unblocked.** The endpoint works; the 500s were a wrong path plus an oversized window (see Blocker). Mapping details inferred from retired FROST data can now be checked against live responses and against the Diver-HUB swagger.
-- Vendor escalation resolved, or a workaround agreed (vendor export, alternate endpoint, SFTP drop).
+- Authenticate: POST credentials to the login endpoint, hold the 1-hour JWT, re-acquire on expiry or on a 401. Credentials and token never committed and never logged.
+- Measure the window ceiling. Three months is known-good; find where it breaks so the chunk size is chosen rather than guessed. Record the number here.
- Confirmed against live responses: `ts` format and timezone; `gs` unit is feet; `approvedWaterLevelsGs` and `unApprovedWaterLevelsGs` are the complete, non-overlapping set; whether `groundSurfaceData` elevation is needed and how it's time-scoped.
+- Reconcile the swagger against `docs/sources/san_acacia.md`: the locations endpoint and the `/api/api/` doubled segment were both taken from the old assumption and may not survive.
- `drillingDepth` centimetres (÷ 30.48) confirmed, not back-calculated.
-- Fixture responses committed for tests.
+- Fixture responses committed for tests, credentials scrubbed.
- `docs/sources/san_acacia.md` copied into OcotilloAPI and corrected.
-Datum and vendor-flag questions are already settled in the Epic — `vrd` is not ingested, and the vendor flag does not map to `review_status`. Blocks live verification of 2.3 and 4.2 only.
+Datum and vendor-flag questions are already settled in the Epic — `vrd` is not ingested, and the vendor flag does not map to `review_status`.
### 2.2 — dlt resource: locations → GCS
@@ -185,13 +198,14 @@ Datum and vendor-flag questions are already settled in the Epic — `vrd` is not
### 2.3 — dlt resource: readings → GCS, incremental
- `@dlt.resource(name="vanessen_readings")` per monitoring point, dlt incremental cursor on reading timestamp, `write_disposition="append"`.
+- **Windowed requests.** `DiverData/ByMonitoringPoint/{id}` takes `startTime`/`endTime` as Unix seconds and 500s on an oversized span, so a fetch is always a sequence of bounded windows — never one open-ended call. This is true of the daily incremental run too, not just backfill: an entity whose cursor has fallen months behind must walk forward in chunks.
+- **Token refresh mid-run.** The JWT expires after an hour. Refresh on expiry and retry once on a 401; a multi-hour backfill must not die at minute 61.
+- Treat a 500 on a windowed request as a signal to halve the window and retry, not as a dead entity — the endpoint reports "too much data" that way.
- `initial_start_date` in `.dlt/config.toml`, documented as a floor for entities with no cursor yet — never a backfill lever (`BACKFILL_STRATEGY.md` §2).
- Vendor approved/unapproved flag preserved per row.
- Per-entity failure doesn't abort the run; failures counted and surfaced as asset metadata.
- Asset `raw_san_acacia_readings` emits rows-ingested and entities-failed. Tested against fixtures.
-Blocked on 2.1 for live verification.
-
---
# TASK 3 — Domain mapping and load into Ocotillo
From e45b6e41535e555d86330e1bb95774e6ff636e4c Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 12:24:01 -0700
Subject: [PATCH 063/151] ci(ingestion): add the Dagster+ code location and its
deploy workflows
Declares `ocotillo-automated-ingestion` and wires up two workflows on the
pinned dagster-cloud-action v1.13.18: a prod deploy and a per-PR branch
deployment. Neither can run until the Dagster+ secrets exist, so nothing
deploys yet.
Prod triggers on `production` rather than `main`. The plan said `main`, but
that branch was abandoned in July 2025 and is not part of the release flow.
Both workflows generate requirements.txt with `uv export --group ingestion`,
because Dagster+ builds from a requirements file and this repo does not keep
one committed. The generation has to follow parse_workspace, which runs its own
checkout and would otherwise clean the file away.
Path filters include pyproject.toml and uv.lock, not just automated_ingestion/,
since the built image is defined by the exported lockfile.
Co-Authored-By: Claude Opus 5
---
.github/workflows/CD_dagster_branch.yml | 69 ++++++++++++++++++++
.github/workflows/CD_dagster_prod.yml | 76 +++++++++++++++++++++++
dagster_cloud.yaml | 16 +++++
docs/automated-ingestion-pipeline-plan.md | 20 ++++--
4 files changed, 175 insertions(+), 6 deletions(-)
create mode 100644 .github/workflows/CD_dagster_branch.yml
create mode 100644 .github/workflows/CD_dagster_prod.yml
create mode 100644 dagster_cloud.yaml
diff --git a/.github/workflows/CD_dagster_branch.yml b/.github/workflows/CD_dagster_branch.yml
new file mode 100644
index 000000000..9e39c4e1c
--- /dev/null
+++ b/.github/workflows/CD_dagster_branch.yml
@@ -0,0 +1,69 @@
+# Creates a Dagster+ branch deployment for a pull request, so ingestion changes
+# can be materialized against an isolated deployment before they reach prod.
+#
+# Path-filtered: most PRs in this repository touch only the API and should not
+# create a Dagster+ deployment at all.
+name: CD (Dagster+ branch deployment)
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, closed]
+ paths:
+ - "automated_ingestion/**"
+ - "dagster_cloud.yaml"
+ - "pyproject.toml"
+ - "uv.lock"
+ - ".github/workflows/CD_dagster_branch.yml"
+
+permissions:
+ contents: read
+ pull-requests: write
+
+# One deployment per PR; a force-push supersedes the run it interrupts.
+concurrency:
+ group: dagster-branch-deploy-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ dagster-branch-deploy:
+ runs-on: ubuntu-latest
+ # Forks cannot read the Dagster+ secrets, and a branch deployment from an
+ # untrusted fork would run our code against our infrastructure regardless.
+ if: github.event.pull_request.head.repo.full_name == github.repository
+
+ steps:
+ - name: Check out source repository
+ uses: actions/checkout@v7.0.1
+
+ # parse_workspace performs its own `actions/checkout`, which cleans the
+ # working tree. It has to run *before* requirements.txt is generated, or
+ # the generated file is deleted before the deploy step can use it.
+ - name: Parse dagster_cloud.yaml
+ id: parse
+ uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v1.13.18
+ with:
+ dagster_cloud_file: dagster_cloud.yaml
+
+ - name: Install uv in container
+ uses: astral-sh/setup-uv@v10.0.1
+ with:
+ version: "latest"
+
+ - name: Generate requirements.txt
+ run: |
+ uv export \
+ --format requirements-txt \
+ --no-emit-project \
+ --no-dev \
+ --group ingestion \
+ --output-file requirements.txt
+
+ # Runs on `closed` too: the action tears the branch deployment down when
+ # the PR is merged or abandoned, so stale deployments do not accumulate.
+ - name: Deploy to Dagster+ branch deployment
+ uses: dagster-io/dagster-cloud-action/actions/serverless_branch_deploy@v1.13.18
+ with:
+ organization_id: ${{ secrets.DAGSTER_CLOUD_ORGANIZATION_ID }}
+ dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+ location: ${{ toJson(fromJson(steps.parse.outputs.build_info)[0]) }}
+ checkout_repo: false
diff --git a/.github/workflows/CD_dagster_prod.yml b/.github/workflows/CD_dagster_prod.yml
new file mode 100644
index 000000000..fcc60a1c7
--- /dev/null
+++ b/.github/workflows/CD_dagster_prod.yml
@@ -0,0 +1,76 @@
+# Deploys the `ocotillo-automated-ingestion` code location to the Dagster+ prod
+# deployment.
+#
+# Triggered on `production`, not `main`: `main` was abandoned in July 2025 and
+# the release flow runs feature -> staging -> production (docs/release-flow.md).
+# The plan document's reference to `main` predates that being checked.
+#
+# Path-filtered so an ordinary API change does not spend a Dagster+ build. The
+# filter includes pyproject.toml and uv.lock because the location's dependency
+# set is exported from them, so a lockfile bump changes the built image even
+# when no ingestion source file does.
+name: CD (Dagster+ prod)
+
+on:
+ push:
+ branches: [production]
+ paths:
+ - "automated_ingestion/**"
+ - "dagster_cloud.yaml"
+ - "pyproject.toml"
+ - "uv.lock"
+ - ".github/workflows/CD_dagster_prod.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: dagster-prod-deploy
+ cancel-in-progress: false
+
+jobs:
+ dagster-prod-deploy:
+ runs-on: ubuntu-latest
+ environment: production
+
+ steps:
+ - name: Check out source repository
+ uses: actions/checkout@v7.0.1
+
+ # parse_workspace performs its own `actions/checkout`, which cleans the
+ # working tree. It has to run *before* requirements.txt is generated, or
+ # the generated file is deleted before the deploy step can use it.
+ - name: Parse dagster_cloud.yaml
+ id: parse
+ uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v1.13.18
+ with:
+ dagster_cloud_file: dagster_cloud.yaml
+
+ - name: Install uv in container
+ uses: astral-sh/setup-uv@v10.0.1
+ with:
+ version: "latest"
+
+ # Dagster+ builds from a requirements.txt, which the repo does not keep
+ # under version control. `--group ingestion` adds dagster and dlt on top
+ # of the runtime dependencies; the runtime ones are needed too, because
+ # the loader imports `db/` and `domain/`.
+ - name: Generate requirements.txt
+ run: |
+ uv export \
+ --format requirements-txt \
+ --no-emit-project \
+ --no-dev \
+ --group ingestion \
+ --output-file requirements.txt
+
+ # checkout_repo is false because requirements.txt is generated above and
+ # a second checkout would discard it.
+ - name: Deploy to Dagster+ prod
+ uses: dagster-io/dagster-cloud-action/actions/serverless_prod_deploy@v1.13.18
+ with:
+ organization_id: ${{ secrets.DAGSTER_CLOUD_ORGANIZATION_ID }}
+ dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+ location: ${{ toJson(fromJson(steps.parse.outputs.build_info)[0]) }}
+ checkout_repo: false
diff --git a/dagster_cloud.yaml b/dagster_cloud.yaml
new file mode 100644
index 000000000..342a9a4d3
--- /dev/null
+++ b/dagster_cloud.yaml
@@ -0,0 +1,16 @@
+# Dagster+ code locations for this repository.
+#
+# The API and the ingestion pipeline share a repo but not a runtime: this file
+# describes only what Dagster+ builds and runs. `module_name` mirrors
+# `[tool.dagster]` in pyproject.toml, so `dagster dev` locally and the Dagster+
+# agent load the same entry point.
+#
+# `directory` is the repository root rather than `automated_ingestion/` because
+# the loader imports `db/` models and `domain/` rules -- the package is not
+# self-contained by design (see docs/automated-ingestion-pipeline-plan.md).
+locations:
+ - location_name: ocotillo-automated-ingestion
+ code_source:
+ module_name: automated_ingestion.defs.definitions
+ build:
+ directory: ./
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index 05befd2a5..d796a99f9 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -140,13 +140,21 @@ Lives in this repo so the loader can import `db/` models and `domain/` rules rat
### 1.2 — Register as a Dagster+ code location with CI deploy
-- `dagster_cloud.yaml` declaring `ocotillo-automated-ingestion` → `automated_ingestion.defs.definitions`.
-- Prod (`main`) and branch-deployment (`pull_request`) workflows, modeled on Aqueduct's, same pinned action version.
-- `DAGSTER_CLOUD_API_TOKEN` and `ORGANIZATION_ID` secrets set.
-- Path-filtered to `automated_ingestion/**` so ordinary API PRs don't trigger a Dagster deploy.
-- Test PR yields a working branch deployment; merge yields a working prod location.
+Files written; nothing deployed yet — the secrets do not exist, so neither workflow has run.
-Confirm whether PEX fast deploys work with this dependency set or it falls back to Docker — determines build times.
+- ✅ `dagster_cloud.yaml` declaring `ocotillo-automated-ingestion` → `automated_ingestion.defs.definitions`, build directory `./`. The build directory is the repository root, not `automated_ingestion/`, because the loader imports `db/` and `domain/`.
+- ✅ `CD_dagster_prod.yml` and `CD_dagster_branch.yml`, both on `dagster-io/dagster-cloud-action@v1.13.18` — pinned to the same version as the installed dagster.
+- ✅ Path-filtered to `automated_ingestion/**`, `dagster_cloud.yaml`, `pyproject.toml`, and `uv.lock`. The last two matter: the location's dependency set is exported from them, so a lockfile bump changes the built image even when no ingestion file moves.
+- ⬜ `DAGSTER_CLOUD_API_TOKEN` and `DAGSTER_CLOUD_ORGANIZATION_ID` repository secrets.
+- ⬜ Test PR yields a working branch deployment; merge to `production` yields a working prod location.
+
+**Prod deploys from `production`, not `main`.** `main` was abandoned in July 2025 — it is 3,839 commits behind and is not part of the release flow (`docs/release-flow.md`). The `main` reference in the original draft was inherited from Aqueduct's layout without checking this repository's.
+
+**PEX vs Docker — answered: Docker.** `serverless_prod_deploy` and `serverless_branch_deploy` build with `docker/build-push-action` and a copied Dockerfile template; there is no PEX fast-deploy path in these actions. So build time is a full image build, and the dependency set matters: the image installs all 197 exported packages (the 135 runtime ones plus dagster, dlt, gcsfs, pyarrow). `pymssql` and `psycopg2-binary` are in that set and compile from source on some base images — the first real build is where that surfaces.
+
+**Ordering constraint, easy to break.** `utils/parse_workspace` runs its own `actions/checkout`, which cleans the working tree. It must run *before* `requirements.txt` is generated; putting the generation first silently deletes it, and the deploy fails on a missing file rather than on the real cause.
+
+Both workflows generate `requirements.txt` with `uv export --group ingestion`, since Dagster+ builds from a requirements file and the repository does not keep one under version control.
### 1.3 — Provision GCS buckets and ingestion service account
From 5188fa447f93f729e4708f35a5c219a55d5895ce Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 13:06:51 -0700
Subject: [PATCH 064/151] ci(ingestion): read the Dagster+ org id from a
variable, not a secret
The organization id appears in the Dagster+ console URL and is not sensitive,
so it belongs with GCP_PROJECT_ID in repository variables. The API token stays
a repository secret: it is a CI credential the action uses to reach Dagster+,
the same class as CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY. Sourcing it from Secret
Manager instead would still need a GitHub secret to authenticate to GCP first,
which adds a hop without removing a trust root.
Records where the runtime secrets go, which is the opposite answer: the
Diver-HUB login, ingestion service account, and Postgres role are read while
the pipeline runs, so those follow the internal-ogc-api-keys precedent and live
in Secret Manager.
Co-Authored-By: Claude Opus 5
---
.github/workflows/CD_dagster_branch.yml | 2 +-
.github/workflows/CD_dagster_prod.yml | 2 +-
docs/automated-ingestion-pipeline-plan.md | 3 ++-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/CD_dagster_branch.yml b/.github/workflows/CD_dagster_branch.yml
index 9e39c4e1c..723da0edc 100644
--- a/.github/workflows/CD_dagster_branch.yml
+++ b/.github/workflows/CD_dagster_branch.yml
@@ -63,7 +63,7 @@ jobs:
- name: Deploy to Dagster+ branch deployment
uses: dagster-io/dagster-cloud-action/actions/serverless_branch_deploy@v1.13.18
with:
- organization_id: ${{ secrets.DAGSTER_CLOUD_ORGANIZATION_ID }}
+ organization_id: ${{ vars.DAGSTER_CLOUD_ORGANIZATION_ID }}
dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
location: ${{ toJson(fromJson(steps.parse.outputs.build_info)[0]) }}
checkout_repo: false
diff --git a/.github/workflows/CD_dagster_prod.yml b/.github/workflows/CD_dagster_prod.yml
index fcc60a1c7..91966767e 100644
--- a/.github/workflows/CD_dagster_prod.yml
+++ b/.github/workflows/CD_dagster_prod.yml
@@ -70,7 +70,7 @@ jobs:
- name: Deploy to Dagster+ prod
uses: dagster-io/dagster-cloud-action/actions/serverless_prod_deploy@v1.13.18
with:
- organization_id: ${{ secrets.DAGSTER_CLOUD_ORGANIZATION_ID }}
+ organization_id: ${{ vars.DAGSTER_CLOUD_ORGANIZATION_ID }}
dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
location: ${{ toJson(fromJson(steps.parse.outputs.build_info)[0]) }}
checkout_repo: false
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index d796a99f9..b02732d72 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -145,7 +145,8 @@ Files written; nothing deployed yet — the secrets do not exist, so neither wor
- ✅ `dagster_cloud.yaml` declaring `ocotillo-automated-ingestion` → `automated_ingestion.defs.definitions`, build directory `./`. The build directory is the repository root, not `automated_ingestion/`, because the loader imports `db/` and `domain/`.
- ✅ `CD_dagster_prod.yml` and `CD_dagster_branch.yml`, both on `dagster-io/dagster-cloud-action@v1.13.18` — pinned to the same version as the installed dagster.
- ✅ Path-filtered to `automated_ingestion/**`, `dagster_cloud.yaml`, `pyproject.toml`, and `uv.lock`. The last two matter: the location's dependency set is exported from them, so a lockfile bump changes the built image even when no ingestion file moves.
-- ⬜ `DAGSTER_CLOUD_API_TOKEN` and `DAGSTER_CLOUD_ORGANIZATION_ID` repository secrets.
+- ⬜ `DAGSTER_CLOUD_API_TOKEN` as a repository **secret**, `DAGSTER_CLOUD_ORGANIZATION_ID` as a repository **variable**. The token is a CI credential the action uses to reach Dagster+, so it belongs with `CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY` rather than in Secret Manager -- reading it from Secret Manager would still require a GitHub secret to authenticate to GCP first, adding a hop without removing a trust root. The organization ID is not sensitive; it appears in the Dagster+ console URL.
+- ⬜ Runtime secrets are a different question and are **not** GitHub's. The Diver-HUB login (2.1), the ingestion service account (1.3), and the Postgres role (1.4) are read by the pipeline while it runs, not by the deploy, so they belong in Secret Manager on the `internal-ogc-api-keys` precedent, reached from Dagster+ at runtime.
- ⬜ Test PR yields a working branch deployment; merge to `production` yields a working prod location.
**Prod deploys from `production`, not `main`.** `main` was abandoned in July 2025 — it is 3,839 commits behind and is not part of the release flow (`docs/release-flow.md`). The `main` reference in the original draft was inherited from Aqueduct's layout without checking this repository's.
From 475841da0b0cbb3e7f4fdd91974318de34fc445e Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 13:13:13 -0700
Subject: [PATCH 065/151] feat(ingestion): add raw-zone infrastructure and
database connectivity
1.3 -- Terraform for the two raw-zone buckets and a service account whose
storage grant is bound on those buckets rather than at project level. Objects
age into colder classes instead of being deleted, since an old window is what a
historical replay reads. Bucket resolution raises rather than defaulting, and
rejects a value equal to GCS_BUCKET_NAME: that variable is the API's upload
bucket, and confusing the two would write vendor payloads into it silently.
1.4 -- A database resource delegating to db/engine.py's Cloud SQL path instead
of building a second engine, imported lazily so that listing assets does not
require a reachable database. A read-only connectivity asset makes the
serverless-to-Cloud-SQL assumption fail loudly and on its own.
The role DDL grants writes on the observation and reference tables only. thing
and location stay read-only because reconciliation matches existing rows rather
than inventing them, and parameter's continuum versioning means the grant also
has to cover parameter_version and transaction, which the code never names.
Neither is applied: no GCP credentials in this environment, and creating roles
or buckets is not something to do implicitly.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/defs/assets/__init__.py | 3 +-
.../defs/assets/connectivity.py | 62 +++++++++++
automated_ingestion/defs/definitions.py | 6 +-
automated_ingestion/defs/resources.py | 51 +++++++++
automated_ingestion/iac/.gitignore | 6 +
automated_ingestion/iac/main.tf | 103 ++++++++++++++++++
automated_ingestion/iac/outputs.tf | 9 ++
.../iac/terraform.tfvars.example | 1 +
automated_ingestion/iac/variables.tf | 16 +++
automated_ingestion/shared/gcs.py | 26 +++++
automated_ingestion/sql/ingestion_role.sql | 68 ++++++++++++
.../tests/test_connectivity.py | 45 ++++++++
automated_ingestion/tests/test_gcs.py | 62 +++++++++++
docs/automated-ingestion-pipeline-plan.md | 30 +++--
14 files changed, 475 insertions(+), 13 deletions(-)
create mode 100644 automated_ingestion/defs/assets/connectivity.py
create mode 100644 automated_ingestion/defs/resources.py
create mode 100644 automated_ingestion/iac/.gitignore
create mode 100644 automated_ingestion/iac/main.tf
create mode 100644 automated_ingestion/iac/outputs.tf
create mode 100644 automated_ingestion/iac/terraform.tfvars.example
create mode 100644 automated_ingestion/iac/variables.tf
create mode 100644 automated_ingestion/sql/ingestion_role.sql
create mode 100644 automated_ingestion/tests/test_connectivity.py
create mode 100644 automated_ingestion/tests/test_gcs.py
diff --git a/automated_ingestion/defs/assets/__init__.py b/automated_ingestion/defs/assets/__init__.py
index 37d1d2fed..64621845b 100644
--- a/automated_ingestion/defs/assets/__init__.py
+++ b/automated_ingestion/defs/assets/__init__.py
@@ -22,12 +22,13 @@
from dagster import AssetsDefinition
+from automated_ingestion.defs.assets.connectivity import database_connectivity
from automated_ingestion.defs.assets.heartbeat import ingestion_heartbeat
def all_assets() -> list[AssetsDefinition]:
"""Every asset the code location exposes."""
- return [ingestion_heartbeat]
+ return [ingestion_heartbeat, database_connectivity]
# ============= EOF =============================================
diff --git a/automated_ingestion/defs/assets/connectivity.py b/automated_ingestion/defs/assets/connectivity.py
new file mode 100644
index 000000000..c8c83fcbc
--- /dev/null
+++ b/automated_ingestion/defs/assets/connectivity.py
@@ -0,0 +1,62 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Proves the Dagster+ runtime can reach Ocotillo Postgres.
+
+Dagster+ Serverless runs outside the VPC, so Cloud SQL's private IP is
+unreachable from it -- the connection has to go through the Cloud SQL connector
+instead. That is the single riskiest assumption in the foundations task, and it
+fails at run time rather than at deploy time. This asset makes it fail loudly,
+on its own, in an asset whose only job is to fail there.
+
+It reads and never writes: connectivity and permission are separable problems,
+and a write here would leave test rows in a real table.
+"""
+
+from dagster import AssetExecutionContext, MetadataValue, Output, asset
+
+from automated_ingestion.defs.resources import OcotilloDatabase
+
+
+@asset(
+ group_name="operations",
+ description="Reads from Ocotillo Postgres to prove the runtime can connect.",
+)
+def database_connectivity(
+ context: AssetExecutionContext, database: OcotilloDatabase
+) -> Output[int]:
+ """Count transducer observations, returning the count as metadata."""
+ from sqlalchemy import func, select
+
+ from db.transducer import TransducerObservation
+
+ with database.session() as session:
+ count = session.scalar(select(func.count()).select_from(TransducerObservation))
+
+ count = int(count or 0)
+ context.log.info("connected to Ocotillo; transducer_observation rows: %s", count)
+ return Output(
+ count,
+ metadata={
+ "transducer_observation_rows": MetadataValue.int(count),
+ "note": MetadataValue.text(
+ "Read-only. A failure here is connectivity or grants, not data."
+ ),
+ },
+ )
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/defs/definitions.py b/automated_ingestion/defs/definitions.py
index 2b525dded..aabacc42a 100644
--- a/automated_ingestion/defs/definitions.py
+++ b/automated_ingestion/defs/definitions.py
@@ -24,7 +24,11 @@
from dagster import Definitions
from automated_ingestion.defs.assets import all_assets
+from automated_ingestion.defs.resources import OcotilloDatabase
-defs = Definitions(assets=all_assets())
+defs = Definitions(
+ assets=all_assets(),
+ resources={"database": OcotilloDatabase()},
+)
# ============= EOF =============================================
diff --git a/automated_ingestion/defs/resources.py b/automated_ingestion/defs/resources.py
new file mode 100644
index 000000000..6e11316d8
--- /dev/null
+++ b/automated_ingestion/defs/resources.py
@@ -0,0 +1,51 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Dagster resources: the pipeline's handles on the outside world.
+
+The database resource deliberately delegates to ``db/engine.py`` rather than
+building its own engine. Connection setup for Cloud SQL -- the connector, IAM
+auth, the IP-type choice -- is intricate and already solved there; a second
+implementation would be a second thing to get wrong, and would drift.
+"""
+
+from collections.abc import Iterator
+from contextlib import contextmanager
+
+from dagster import ConfigurableResource
+
+
+class OcotilloDatabase(ConfigurableResource):
+ """A session against the Ocotillo database.
+
+ Configured entirely through the environment that ``db/engine.py`` reads
+ (``DB_DRIVER``, ``CLOUD_SQL_*``), so the Dagster+ code location is
+ configured the same way the API is, with different credentials.
+ """
+
+ @contextmanager
+ def session(self) -> Iterator[object]:
+ """Yield a SQLAlchemy session, rolled back and closed on the way out."""
+ # Imported lazily: importing db.engine builds an engine from the
+ # environment at import time, which should happen when a run asks for a
+ # session, not when Dagster loads the code location to list assets.
+ from db.engine import session_ctx
+
+ with session_ctx() as session:
+ yield session
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/iac/.gitignore b/automated_ingestion/iac/.gitignore
new file mode 100644
index 000000000..72869f3b0
--- /dev/null
+++ b/automated_ingestion/iac/.gitignore
@@ -0,0 +1,6 @@
+.terraform/
+.terraform.lock.hcl
+terraform.tfstate
+terraform.tfstate.*
+terraform.tfvars
+*.tfplan
diff --git a/automated_ingestion/iac/main.tf b/automated_ingestion/iac/main.tf
new file mode 100644
index 000000000..9df05f48a
--- /dev/null
+++ b/automated_ingestion/iac/main.tf
@@ -0,0 +1,103 @@
+# Raw-zone storage for the automated ingestion pipeline.
+#
+# Two buckets, one per environment, plus the service account the Dagster+ code
+# location uses to write to them. Deliberately narrow: this configuration owns
+# ingestion storage and nothing else, so a mistake here cannot affect the API's
+# uploads bucket or any other project resource.
+#
+# Not applied by CI. Run it by hand, review the plan, and record the applied
+# state -- see README.md.
+
+terraform {
+ required_version = ">= 1.5"
+ required_providers {
+ google = {
+ source = "hashicorp/google"
+ version = "~> 6.0"
+ }
+ }
+}
+
+provider "google" {
+ project = var.project_id
+ region = var.region
+}
+
+locals {
+ environments = toset(["production", "staging"])
+}
+
+resource "google_storage_bucket" "ingestion_raw" {
+ for_each = local.environments
+
+ name = "ocotillo-ingestion-${each.key}"
+ project = var.project_id
+ location = var.bucket_location
+
+ # The raw zone is the replay source for Mode B backfill: reprocessing a
+ # mapping bug must not depend on the vendor still serving that window.
+ # Deleting an object here is therefore a data-loss event, not a cleanup.
+ force_destroy = false
+ uniform_bucket_level_access = true
+ public_access_prevention = "enforced"
+
+ versioning {
+ enabled = true
+ }
+
+ # Raw payloads are read constantly for the first month (recent-window
+ # replays), then almost never. Age-out to colder classes rather than
+ # deleting: an old window is exactly what a historical replay needs.
+ lifecycle_rule {
+ condition {
+ age = 30
+ }
+ action {
+ type = "SetStorageClass"
+ storage_class = "NEARLINE"
+ }
+ }
+
+ lifecycle_rule {
+ condition {
+ age = 365
+ }
+ action {
+ type = "SetStorageClass"
+ storage_class = "COLDLINE"
+ }
+ }
+
+ # Bucket versioning would otherwise retain every superseded object forever.
+ lifecycle_rule {
+ condition {
+ num_newer_versions = 3
+ with_state = "ARCHIVED"
+ }
+ action {
+ type = "Delete"
+ }
+ }
+
+ labels = {
+ component = "automated-ingestion"
+ env = each.key
+ }
+}
+
+resource "google_service_account" "ingestion" {
+ account_id = "ocotillo-ingestion"
+ display_name = "Ocotillo automated ingestion"
+ description = "Writes raw vendor payloads to the ingestion buckets from the Dagster+ code location."
+ project = var.project_id
+}
+
+# Scoped to the two buckets, not granted at project level. objectAdmin rather
+# than objectCreator because a replay overwrite rewrites an existing object.
+resource "google_storage_bucket_iam_member" "ingestion_object_admin" {
+ for_each = google_storage_bucket.ingestion_raw
+
+ bucket = each.value.name
+ role = "roles/storage.objectAdmin"
+ member = "serviceAccount:${google_service_account.ingestion.email}"
+}
diff --git a/automated_ingestion/iac/outputs.tf b/automated_ingestion/iac/outputs.tf
new file mode 100644
index 000000000..77b57e6fc
--- /dev/null
+++ b/automated_ingestion/iac/outputs.tf
@@ -0,0 +1,9 @@
+output "bucket_names" {
+ description = "Raw-zone bucket per environment. The matching value goes into INGESTION_GCS_BUCKET on the Dagster+ code location."
+ value = { for k, b in google_storage_bucket.ingestion_raw : k => b.name }
+}
+
+output "service_account_email" {
+ description = "Ingestion service account. Grant nothing else to it without revisiting the least-privilege rationale in README.md."
+ value = google_service_account.ingestion.email
+}
diff --git a/automated_ingestion/iac/terraform.tfvars.example b/automated_ingestion/iac/terraform.tfvars.example
new file mode 100644
index 000000000..6e1830113
--- /dev/null
+++ b/automated_ingestion/iac/terraform.tfvars.example
@@ -0,0 +1 @@
+project_id = "waterdatainitiative-271000"
diff --git a/automated_ingestion/iac/variables.tf b/automated_ingestion/iac/variables.tf
new file mode 100644
index 000000000..c06187f4e
--- /dev/null
+++ b/automated_ingestion/iac/variables.tf
@@ -0,0 +1,16 @@
+variable "project_id" {
+ type = string
+ description = "GCP project that owns the ingestion buckets and service account."
+}
+
+variable "region" {
+ type = string
+ description = "Default provider region."
+ default = "us-central1"
+}
+
+variable "bucket_location" {
+ type = string
+ description = "Bucket location. US-CENTRAL1 keeps the raw zone in the same region as Cloud SQL, so replay reads do not cross regions."
+ default = "US-CENTRAL1"
+}
diff --git a/automated_ingestion/shared/gcs.py b/automated_ingestion/shared/gcs.py
index 3bd6293ab..b98b8836f 100644
--- a/automated_ingestion/shared/gcs.py
+++ b/automated_ingestion/shared/gcs.py
@@ -31,4 +31,30 @@
RAW_LAYOUT = "{table_name}/year={YYYY}/month={MM}/day={DD}/{load_id}.{file_id}.{ext}"
"""dlt filesystem layout for the raw zone."""
+
+def raw_zone_bucket() -> str:
+ """Name of the raw-zone bucket for this environment.
+
+ Raises rather than defaulting. A wrong bucket name is not a condition worth
+ guessing through: the failure would be a run that reports success while
+ writing nowhere useful, or worse, into a bucket that belongs to something
+ else.
+ """
+ import os
+
+ bucket = os.environ.get(BUCKET_ENV_VAR, "").strip()
+ if not bucket:
+ raise RuntimeError(
+ f"{BUCKET_ENV_VAR} is not set. The ingestion raw zone has no default; "
+ "set it on the Dagster+ code location to the bucket Terraform "
+ "created (see automated_ingestion/iac)."
+ )
+ if bucket == os.environ.get("GCS_BUCKET_NAME", "").strip():
+ raise RuntimeError(
+ f"{BUCKET_ENV_VAR} points at GCS_BUCKET_NAME, the API's user-upload "
+ "bucket. Raw vendor payloads must not be written there."
+ )
+ return bucket
+
+
# ============= EOF =============================================
diff --git a/automated_ingestion/sql/ingestion_role.sql b/automated_ingestion/sql/ingestion_role.sql
new file mode 100644
index 000000000..65afb657b
--- /dev/null
+++ b/automated_ingestion/sql/ingestion_role.sql
@@ -0,0 +1,68 @@
+-- Least-privilege Postgres role for the automated ingestion pipeline.
+--
+-- Run by hand against each environment as a superuser. Not an Alembic
+-- migration: roles and grants are per-environment infrastructure, not schema,
+-- and migrations run under this database's application role rather than a
+-- superuser.
+--
+-- The point of the role is blast radius. The pipeline writes observations and
+-- the reference rows they hang from, and reads everything it must resolve
+-- against. It cannot touch chemistry, contacts, assets, or the legacy NMA_*
+-- and NMW_* tables, so a bug in an adapter cannot corrupt data no ingestion
+-- path should ever reach.
+
+-- Set the password out of band; do not commit it. It belongs in Secret
+-- Manager alongside internal-ogc-api-keys.
+-- CREATE ROLE ocotillo_ingestion LOGIN PASSWORD '...';
+--
+-- Or, preferred, use IAM database authentication and create the role for the
+-- service account instead, so there is no password to rotate:
+-- CREATE ROLE "ocotillo-ingestion@PROJECT.iam" WITH LOGIN;
+-- GRANT cloudsqliamuser TO "ocotillo-ingestion@PROJECT.iam";
+
+\set role_name ocotillo_ingestion
+
+GRANT CONNECT ON DATABASE :"db_name" TO :"role_name";
+GRANT USAGE ON SCHEMA public TO :"role_name";
+
+-- Written: the observations themselves and the rows a new series needs.
+GRANT SELECT, INSERT, UPDATE ON
+ transducer_observation,
+ transducer_observation_block,
+ deployment,
+ sensor,
+ parameter
+TO :"role_name";
+
+-- `parameter` is versioned by sqlalchemy-continuum, so an insert there also
+-- writes a version row and a transaction row. Without these two grants the
+-- write fails at runtime with a permission error on a table the code never
+-- names directly -- an unpleasant thing to debug.
+GRANT SELECT, INSERT ON parameter_version, transaction TO :"role_name";
+
+-- Read-only: resolved against, never written. `thing` and `location` are
+-- deliberately not writable. Reconciling the 33 San Acacia wells means
+-- matching them to rows that already exist; if reconciliation finds a well
+-- missing, that is a decision for a human, not a row the pipeline invents.
+GRANT SELECT ON
+ thing,
+ thing_id_link,
+ location,
+ lexicon_term,
+ lexicon_category,
+ lexicon_term_category_association
+TO :"role_name";
+
+-- Inserts need the sequences behind the autoincrement primary keys.
+GRANT USAGE, SELECT ON SEQUENCE
+ transducer_observation_id_seq,
+ transducer_observation_block_id_seq,
+ deployment_id_seq,
+ sensor_id_seq,
+ parameter_id_seq,
+ transaction_id_seq
+TO :"role_name";
+
+-- No default privileges are granted. A table added later is invisible to this
+-- role until someone grants it deliberately, which is the intended failure
+-- mode: a new table reaching the pipeline should be a decision.
diff --git a/automated_ingestion/tests/test_connectivity.py b/automated_ingestion/tests/test_connectivity.py
new file mode 100644
index 000000000..b90728bfb
--- /dev/null
+++ b/automated_ingestion/tests/test_connectivity.py
@@ -0,0 +1,45 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+The connectivity asset is wired up, and does not reach the database until run.
+
+Loading the code location must not open a connection: Dagster lists assets far
+more often than it runs them, and a code location that needs a database to load
+is a code location that breaks whenever the database is briefly unreachable.
+"""
+
+from dagster import AssetKey
+
+from automated_ingestion.defs.definitions import defs
+
+
+def test_connectivity_asset_is_registered():
+ assert AssetKey(["database_connectivity"]) in defs.resolve_all_asset_keys()
+
+
+def test_database_resource_is_provided():
+ assert "database" in defs.resources
+
+
+def test_loading_definitions_opens_no_connection():
+ # db.engine builds its engine at import time, so the guard that matters is
+ # that importing the definitions module has not imported it.
+ import sys
+
+ assert "db.engine" not in sys.modules
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_gcs.py b/automated_ingestion/tests/test_gcs.py
new file mode 100644
index 000000000..b9875a1d5
--- /dev/null
+++ b/automated_ingestion/tests/test_gcs.py
@@ -0,0 +1,62 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Bucket resolution refuses to guess.
+
+The uploads-bucket check is the one worth testing: `services/gcs_helper.py`
+already uses GCS_BUCKET_NAME, and the two variables being confused is a
+configuration mistake that would otherwise succeed quietly.
+"""
+
+import pytest
+
+from automated_ingestion.shared.gcs import BUCKET_ENV_VAR, RAW_LAYOUT, raw_zone_bucket
+
+
+def test_returns_the_configured_bucket(monkeypatch):
+ monkeypatch.setenv(BUCKET_ENV_VAR, "ocotillo-ingestion-staging")
+ monkeypatch.delenv("GCS_BUCKET_NAME", raising=False)
+ assert raw_zone_bucket() == "ocotillo-ingestion-staging"
+
+
+def test_unset_bucket_raises(monkeypatch):
+ monkeypatch.delenv(BUCKET_ENV_VAR, raising=False)
+ with pytest.raises(RuntimeError, match=BUCKET_ENV_VAR):
+ raw_zone_bucket()
+
+
+def test_blank_bucket_raises(monkeypatch):
+ monkeypatch.setenv(BUCKET_ENV_VAR, " ")
+ with pytest.raises(RuntimeError, match=BUCKET_ENV_VAR):
+ raw_zone_bucket()
+
+
+def test_uploads_bucket_is_rejected(monkeypatch):
+ monkeypatch.setenv(BUCKET_ENV_VAR, "ocotillo-uploads")
+ monkeypatch.setenv("GCS_BUCKET_NAME", "ocotillo-uploads")
+ with pytest.raises(RuntimeError, match="user-upload"):
+ raw_zone_bucket()
+
+
+def test_layout_partitions_by_date():
+ # Mode B replay selects a window by prefix, which only works if the date
+ # is in the path rather than inside the file.
+ assert "year={YYYY}" in RAW_LAYOUT
+ assert "month={MM}" in RAW_LAYOUT
+ assert "day={DD}" in RAW_LAYOUT
+
+
+# ============= EOF =============================================
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index b02732d72..3d72d1329 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -159,22 +159,30 @@ Both workflows generate `requirements.txt` with `uv export --group ingestion`, s
### 1.3 — Provision GCS buckets and ingestion service account
-- `ocotillo-ingestion-production` and `-staging`.
-- dlt layout `{table_name}/year={YYYY}/month={MM}/day={DD}/{load_id}.{file_id}.{ext}`, yielding `raw_sanacaciareach/vanessen_locations/…` and `…/vanessen_readings/…`.
-- SA with `roles/storage.objectAdmin` scoped to those buckets only, wired into the Dagster+ location env.
-- Lifecycle policy set, or deferred with the reason recorded.
+Terraform written in `automated_ingestion/iac/`; **not applied**. `terraform validate` and `fmt` pass, but no GCP credentials were available, so no resource exists yet.
-`services/gcs_helper.py` already uses `GCS_BUCKET_NAME` for user uploads — ingestion must not reuse that variable, or a misconfiguration writes into the uploads bucket.
+- ✅ `ocotillo-ingestion-production` and `-staging`, uniform bucket-level access, public access prevention enforced, `force_destroy = false`.
+- ✅ Service account `ocotillo-ingestion` with `roles/storage.objectAdmin` bound **on the two buckets**, not at project level. `objectAdmin` rather than `objectCreator` because a Mode B replay overwrites an existing object.
+- ✅ Lifecycle: NEARLINE at 30 days, COLDLINE at 365, and archived-version pruning past 3. Aged out rather than deleted — an old window is exactly what a historical replay reads.
+- ✅ `INGESTION_GCS_BUCKET` resolved by `shared/gcs.raw_zone_bucket()`, which raises rather than defaulting and explicitly rejects a value equal to `GCS_BUCKET_NAME`. `services/gcs_helper.py` uses that variable for user uploads; the two being confused would write raw vendor payloads into the uploads bucket, and would otherwise do so silently.
+- ⬜ `terraform apply`, then set `INGESTION_GCS_BUCKET` on the Dagster+ code location.
+
+The dlt layout `{table_name}/year={YYYY}/month={MM}/day={DD}/{load_id}.{file_id}.{ext}` is asserted by a test, because Mode B replay selects a window by prefix — the date has to be in the path, not inside the file.
### 1.4 — DB connectivity from Dagster+ with a least-privilege role
-Dagster+ Serverless is outside the VPC, so Cloud SQL's private IP is unreachable from it.
+Dagster+ Serverless is outside the VPC, so Cloud SQL's private IP is unreachable from it. Code written; **nothing run against a database**.
+
+- ✅ `OcotilloDatabase` resource delegating to `db/engine.py`'s `DB_DRIVER=cloudsql` path rather than building a second engine. The import is lazy: `db.engine` builds its engine at import time, and a code location that needs a reachable database merely to *list* its assets breaks every time the database blips. A test asserts loading the definitions leaves `db.engine` unimported.
+- ✅ `database_connectivity` asset, read-only. Connectivity and grants are separable problems, and a write here would leave test rows in a real table.
+- ✅ Role DDL in `automated_ingestion/sql/ingestion_role.sql`, kept out of Alembic: roles and grants are per-environment infrastructure, not schema, and migrations do not run as a superuser.
+- ⬜ Run the DDL per environment; set `DB_DRIVER`, `CLOUD_SQL_*` on the code location; materialize the asset from both a branch and prod deployment.
+
+**The grant list is narrower than the draft assumed, and one part of it is non-obvious.** Writable: `transducer_observation`, `transducer_observation_block`, `deployment`, `sensor`, `parameter`. Read-only: `thing`, `thing_id_link`, `location`, and the three `lexicon_*` tables — `thing` and `location` deliberately *not* writable, because reconciling the 33 wells means matching rows that already exist. A well found missing is a decision for a human, not a row the pipeline invents.
+
+`parameter` is versioned by sqlalchemy-continuum, so inserting one also writes to `parameter_version` and `transaction`. Without those two grants the write fails on a table the code never names — the kind of error that costs an afternoon. (`transducer_observation` itself is not versioned; only `aquifer_system`, `geologic_formation`, `location`, `observation`, `parameter`, `regulatory_limit`, and `thing` are.) Sequence `USAGE` is granted explicitly, and no default privileges are set: a table added later stays invisible until someone grants it deliberately.
-- Preferred path: reuse `db/engine.py`'s `DB_DRIVER=cloudsql` mode (Cloud SQL Python Connector) — IAM auth, no VPC membership or public-IP allowlist.
-- Dedicated Postgres role: INSERT/UPDATE/SELECT on transducer, thing, location, deployment, lexicon tables only. Not the application role.
-- Credentials via Dagster+ env / Secret Manager, never committed.
-- Trivial asset proves connectivity from branch and prod; no connection leaks across runs.
-- Fallback documented: Hybrid agent in GCP.
+Fallback if the connector path fails: Hybrid agent in GCP.
---
From fd6e86920a27ef373be7782c8e3c2736fe1ec7c1 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 13:27:28 -0700
Subject: [PATCH 066/151] fix(ingestion): repair two CI failures the first PR
run exposed
The connectivity test asserted `db.engine` was absent from sys.modules, which
only held when the file ran alone -- in the full suite another test imports it
first. The property is still worth asserting, since a code location that needs
a reachable database merely to list its assets breaks whenever the database
blips, so it now asks a clean interpreter instead of inspecting its own.
The branch-deploy workflow never reached Dagster+ at all: the action's notify
steps read GITHUB_TOKEN from the workflow environment rather than the secrets
context, and assert it is non-empty. The failure looked like an auth problem
and was a missing env var. Only the branch action posts PR comments, so prod
does not need it.
Co-Authored-By: Claude Opus 5
---
.github/workflows/CD_dagster_branch.yml | 8 +++++++
.../iac/.terraform.tfstate.lock.info | 1 +
.../tests/test_connectivity.py | 23 +++++++++++++++----
3 files changed, 28 insertions(+), 4 deletions(-)
create mode 100644 automated_ingestion/iac/.terraform.tfstate.lock.info
diff --git a/.github/workflows/CD_dagster_branch.yml b/.github/workflows/CD_dagster_branch.yml
index 723da0edc..5e3588231 100644
--- a/.github/workflows/CD_dagster_branch.yml
+++ b/.github/workflows/CD_dagster_branch.yml
@@ -31,6 +31,14 @@ jobs:
# untrusted fork would run our code against our infrastructure regardless.
if: github.event.pull_request.head.repo.full_name == github.repository
+ # The action's notify steps post build status as a PR comment and read the
+ # token from the workflow environment -- `env.GITHUB_TOKEN`, not the
+ # `secrets` context. Without this the run dies on an empty-token assertion
+ # before it ever reaches Dagster+, which reads as an auth failure but is
+ # not one.
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
steps:
- name: Check out source repository
uses: actions/checkout@v7.0.1
diff --git a/automated_ingestion/iac/.terraform.tfstate.lock.info b/automated_ingestion/iac/.terraform.tfstate.lock.info
new file mode 100644
index 000000000..0bf674d26
--- /dev/null
+++ b/automated_ingestion/iac/.terraform.tfstate.lock.info
@@ -0,0 +1 @@
+{"ID":"cc1b7311-6286-a2e9-c3dc-2a8a07c0c22c","Operation":"OperationTypePlan","Info":"","Who":"jakeross@Jakes-MacBook-Pro.local","Version":"1.14.8","Created":"2026-08-18T20:16:28.986227Z","Path":"terraform.tfstate"}
\ No newline at end of file
diff --git a/automated_ingestion/tests/test_connectivity.py b/automated_ingestion/tests/test_connectivity.py
index b90728bfb..da5453139 100644
--- a/automated_ingestion/tests/test_connectivity.py
+++ b/automated_ingestion/tests/test_connectivity.py
@@ -34,12 +34,27 @@ def test_database_resource_is_provided():
assert "database" in defs.resources
-def test_loading_definitions_opens_no_connection():
- # db.engine builds its engine at import time, so the guard that matters is
- # that importing the definitions module has not imported it.
+def test_loading_definitions_does_not_import_db_engine():
+ # db.engine builds its engine at import time, so listing assets must not
+ # reach it. Checking sys.modules in-process would only observe whichever
+ # test imported it first, so ask a clean interpreter instead.
+ import subprocess
import sys
- assert "db.engine" not in sys.modules
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ "import automated_ingestion.defs.definitions as d; "
+ "import sys; "
+ "assert d.defs is not None; "
+ "print('db.engine' in sys.modules)",
+ ],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, result.stderr
+ assert result.stdout.strip() == "False", result.stdout
# ============= EOF =============================================
From 23579ea0956ffababa05a176575c9625d3620faa Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 13:41:25 -0700
Subject: [PATCH 067/151] ci(ingestion): build the code location on python 3.13
The action templates `FROM python:3.8-slim` unless a base image is given. This
project requires 3.13, so pip reported the exported pins as having no matching
distribution -- aiobotocore==3.9.0 among them -- which reads like a corrupt
requirements file rather than a Python version mismatch.
Co-Authored-By: Claude Opus 5
---
.github/workflows/CD_dagster_branch.yml | 5 +++++
.github/workflows/CD_dagster_prod.yml | 5 +++++
2 files changed, 10 insertions(+)
diff --git a/.github/workflows/CD_dagster_branch.yml b/.github/workflows/CD_dagster_branch.yml
index 5e3588231..4267eb9c0 100644
--- a/.github/workflows/CD_dagster_branch.yml
+++ b/.github/workflows/CD_dagster_branch.yml
@@ -75,3 +75,8 @@ jobs:
dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
location: ${{ toJson(fromJson(steps.parse.outputs.build_info)[0]) }}
checkout_repo: false
+ # The action defaults to python:3.8-slim, which cannot install a
+ # lockfile resolved for requires-python >= 3.13 -- pip reports the
+ # pins as having no matching distribution rather than as a version
+ # conflict, which reads like a broken requirements file.
+ base_image: python:3.13-slim
diff --git a/.github/workflows/CD_dagster_prod.yml b/.github/workflows/CD_dagster_prod.yml
index 91966767e..4cfba270c 100644
--- a/.github/workflows/CD_dagster_prod.yml
+++ b/.github/workflows/CD_dagster_prod.yml
@@ -74,3 +74,8 @@ jobs:
dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
location: ${{ toJson(fromJson(steps.parse.outputs.build_info)[0]) }}
checkout_repo: false
+ # The action defaults to python:3.8-slim, which cannot install a
+ # lockfile resolved for requires-python >= 3.13 -- pip reports the
+ # pins as having no matching distribution rather than as a version
+ # conflict, which reads like a broken requirements file.
+ base_image: python:3.13-slim
From e01fd8c34f3eccc7f65000e54118042033d547c3 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 14:00:58 -0700
Subject: [PATCH 068/151] feat(ingestion): add the Diver-HUB client and correct
the source mapping
Reading the public swagger settled most of task 2.1 without credentials, and
overturned four things the plan had inherited from the retired FROST pipeline:
there is no doubled /api/api/ path, there are no gs/vrd arrays, DiverData
carries raw sensor output rather than water level, and MonitoringPoint has
neither coordinates nor drilling depth.
Datum and approval turn out to be query parameters rather than response fields.
That makes WaterLevelReference the risk: the swagger declares enum [0,1,2,3]
with no names, so which value means ground surface cannot be derived, and
choosing wrong returns plausible numbers instead of an error. The constant is
left None and the client refuses to guess; probe_diverhub.py samples all four
so a person can identify it against a well whose depth to water is known.
The client refreshes on validTo rather than an assumed hour, re-logs in once on
a 401 so a clock difference cannot end a backfill, and halves a window on a 500
down to a one-day floor -- below which a failure is not a volume problem and
must surface.
Co-Authored-By: Claude Opus 5
---
.../iac/.terraform.tfstate.lock.info | 1 -
automated_ingestion/scripts/__init__.py | 18 ++
automated_ingestion/scripts/probe_diverhub.py | 154 +++++++++++
automated_ingestion/shared/windows.py | 83 ++++++
.../sources/san_acacia/client.py | 256 ++++++++++++++++++
.../sources/san_acacia/transform.py | 13 +-
.../tests/test_diverhub_client.py | 177 ++++++++++++
automated_ingestion/tests/test_windows.py | 71 +++++
docs/automated-ingestion-pipeline-plan.md | 25 +-
docs/sources/san_acacia.md | 150 ++++++++++
10 files changed, 935 insertions(+), 13 deletions(-)
delete mode 100644 automated_ingestion/iac/.terraform.tfstate.lock.info
create mode 100644 automated_ingestion/scripts/__init__.py
create mode 100644 automated_ingestion/scripts/probe_diverhub.py
create mode 100644 automated_ingestion/shared/windows.py
create mode 100644 automated_ingestion/sources/san_acacia/client.py
create mode 100644 automated_ingestion/tests/test_diverhub_client.py
create mode 100644 automated_ingestion/tests/test_windows.py
create mode 100644 docs/sources/san_acacia.md
diff --git a/automated_ingestion/iac/.terraform.tfstate.lock.info b/automated_ingestion/iac/.terraform.tfstate.lock.info
deleted file mode 100644
index 0bf674d26..000000000
--- a/automated_ingestion/iac/.terraform.tfstate.lock.info
+++ /dev/null
@@ -1 +0,0 @@
-{"ID":"cc1b7311-6286-a2e9-c3dc-2a8a07c0c22c","Operation":"OperationTypePlan","Info":"","Who":"jakeross@Jakes-MacBook-Pro.local","Version":"1.14.8","Created":"2026-08-18T20:16:28.986227Z","Path":"terraform.tfstate"}
\ No newline at end of file
diff --git a/automated_ingestion/scripts/__init__.py b/automated_ingestion/scripts/__init__.py
new file mode 100644
index 000000000..94919dc13
--- /dev/null
+++ b/automated_ingestion/scripts/__init__.py
@@ -0,0 +1,18 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""One-off instruments. Nothing here is imported by the pipeline."""
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/scripts/probe_diverhub.py b/automated_ingestion/scripts/probe_diverhub.py
new file mode 100644
index 000000000..624b28f2d
--- /dev/null
+++ b/automated_ingestion/scripts/probe_diverhub.py
@@ -0,0 +1,154 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Answer the open questions in BDMS task 2.1 against the live Diver-HUB API.
+
+Run once, with credentials, and fold the output into
+``docs/sources/san_acacia.md``. It reads and never writes.
+
+ export DIVERHUB_USERNAME=... DIVERHUB_PASSWORD=...
+ uv run --group ingestion python -m automated_ingestion.scripts.probe_diverhub
+
+What it settles:
+
+* Which project holds San Acacia Reach, and whether it really has 33 points.
+* **Which ``reference`` value is ground surface.** The swagger declares the enum
+ as ``[0, 1, 2, 3]`` and says nothing else, so this prints a sample from each
+ side by side. The ground-surface series is recognisable by magnitude and sign
+ against a well whose depth to water is roughly known -- a judgement a person
+ has to make, which is why this script prints rather than decides.
+* The window ceiling. Three months is known good; this widens until the API
+ answers 500, so the production span is measured rather than guessed.
+
+Nothing here is imported by the pipeline. It is a one-off instrument.
+"""
+
+import sys
+from datetime import datetime, timezone
+
+from automated_ingestion.shared.windows import DAY
+from automated_ingestion.sources.san_acacia.client import (
+ DiverHubClient,
+ DiverHubError,
+)
+
+REFERENCE_VALUES = (0, 1, 2, 3)
+
+
+def _session():
+ import requests
+
+ return requests.Session()
+
+
+def _iso(unix: int) -> str:
+ return datetime.fromtimestamp(unix, tz=timezone.utc).isoformat()
+
+
+def probe_projects(client: DiverHubClient) -> list[dict]:
+ print("== Projects visible to these credentials ==")
+ projects = client.projects()
+ for project in projects:
+ print(f" {project['id']:>6} {project['name']}")
+ return projects
+
+
+def probe_points(client: DiverHubClient, project_id: int) -> list[dict]:
+ print(f"\n== Monitoring points in project {project_id} ==")
+ points = client.monitoring_points(project_id)
+ print(f" {len(points)} points (the plan expects 33)")
+ for point in points[:5]:
+ print(f" {point['id']:>6} {point['name']}")
+ if len(points) > 5:
+ print(f" ... and {len(points) - 5} more")
+ return points
+
+
+def probe_reference_values(client: DiverHubClient, point_id: int, end: int) -> None:
+ """Sample each reference value so a human can tell which is ground surface."""
+ print(f"\n== WaterLevelReference values for point {point_id} ==")
+ print(" Ground surface should read as depth below ground: positive and")
+ print(" plausible as feet-below-surface for this well. An elevation will")
+ print(" look like a much larger number.\n")
+ start = end - 30 * DAY
+ for reference in REFERENCE_VALUES:
+ try:
+ rows = list(client.water_levels(point_id, start, end, reference=reference))
+ except DiverHubError as exc:
+ print(f" reference={reference}: error -- {exc}")
+ continue
+ if not rows:
+ print(f" reference={reference}: no rows in the last 30 days")
+ continue
+ levels = [r["level"] for r in rows if r.get("level") is not None]
+ sample = rows[0]
+ print(
+ f" reference={reference}: {len(rows):>6} rows, "
+ f"min={min(levels):.3f} max={max(levels):.3f} "
+ f"first={sample.get('dateAndTime')} level={sample.get('level')}"
+ )
+
+
+def probe_window_ceiling(client: DiverHubClient, point_id: int, end: int) -> None:
+ """Widen until the API breaks, so the production span is a measurement."""
+ print(f"\n== Window ceiling for point {point_id} ==")
+ for days in (90, 180, 365, 730, 1825):
+ start = end - days * DAY
+ try:
+ rows = list(client.diver_data(point_id, start, end, span=days * DAY))
+ print(f" {days:>5}d ({_iso(start)}): ok, {len(rows)} rows")
+ except DiverHubError as exc:
+ print(f" {days:>5}d: FAILED -- {exc}")
+ print(" ^ ceiling is below this; use the last successful span.")
+ return
+ print(" No ceiling found up to 5 years.")
+
+
+def main() -> int:
+ try:
+ client = DiverHubClient(_session())
+ except DiverHubError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+
+ end = int(datetime.now(tz=timezone.utc).timestamp())
+
+ projects = probe_projects(client)
+ if not projects:
+ print("No projects visible; nothing further to probe.", file=sys.stderr)
+ return 1
+
+ project_id = projects[0]["id"]
+ if len(projects) > 1:
+ print(f"\n(using project {project_id}; pass another by editing this script)")
+
+ points = probe_points(client, project_id)
+ if not points:
+ return 1
+
+ point_id = points[0]["id"]
+ probe_reference_values(client, point_id, end)
+ probe_window_ceiling(client, point_id, end)
+
+ print("\nRecord the findings in docs/sources/san_acacia.md and set")
+ print("GROUND_SURFACE_REFERENCE in sources/san_acacia/client.py.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/shared/windows.py b/automated_ingestion/shared/windows.py
new file mode 100644
index 000000000..9fc8765d6
--- /dev/null
+++ b/automated_ingestion/shared/windows.py
@@ -0,0 +1,83 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Time-window arithmetic for sources that cannot be asked for an open range.
+
+Diver-HUB answers `DiverData` and `WaterLevels` for an explicit
+``startTime``/``endTime`` in Unix seconds, and returns HTTP 500 -- not a
+pagination cursor, not a 413 -- when the span is too wide. So a "fetch this
+series" operation is always a sequence of bounded windows, and the useful
+response to a 500 is to ask for less rather than to give up.
+
+Pure arithmetic, no HTTP: the retry policy that uses it is in the client, and
+the point of separating them is that the tricky part is testable without a
+network.
+"""
+
+from collections.abc import Iterator
+from dataclasses import dataclass
+
+DAY = 86_400
+
+DEFAULT_SPAN = 90 * DAY
+"""Starting window width. Three months is confirmed to work; the ceiling is
+not yet measured, so this is the largest span known to be safe rather than the
+largest span that is."""
+
+MINIMUM_SPAN = DAY
+"""Floor for bisection. A 500 on a single day is a real failure -- something
+other than volume -- and must surface rather than shrink forever."""
+
+
+@dataclass(frozen=True)
+class Window:
+ """A half-open interval in Unix seconds, ``start`` inclusive."""
+
+ start: int
+ end: int
+
+ def __post_init__(self) -> None:
+ if self.end < self.start:
+ raise ValueError(f"Window end {self.end} precedes start {self.start}.")
+
+ @property
+ def span(self) -> int:
+ return self.end - self.start
+
+ def bisect(self) -> tuple["Window", "Window"]:
+ """Split in two. Raises at the floor rather than shrinking forever."""
+ if self.span <= MINIMUM_SPAN:
+ raise ValueError(
+ f"Refusing to split a {self.span}s window below the {MINIMUM_SPAN}s "
+ "floor. A failure this narrow is not a volume problem."
+ )
+ midpoint = self.start + self.span // 2
+ return Window(self.start, midpoint), Window(midpoint, self.end)
+
+
+def iter_windows(start: int, end: int, span: int = DEFAULT_SPAN) -> Iterator[Window]:
+ """Walk ``[start, end]`` in windows of at most ``span`` seconds."""
+ if span <= 0:
+ raise ValueError(f"Window span must be positive, got {span}.")
+ if end < start:
+ raise ValueError(f"End {end} precedes start {start}.")
+ cursor = start
+ while cursor < end:
+ yield Window(cursor, min(cursor + span, end))
+ cursor += span
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/sources/san_acacia/client.py b/automated_ingestion/sources/san_acacia/client.py
new file mode 100644
index 000000000..05aeb248d
--- /dev/null
+++ b/automated_ingestion/sources/san_acacia/client.py
@@ -0,0 +1,256 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Client for the private Diver-HUB API.
+
+Three things about this API shape the code, and all three differ from what the
+retired FROST pipeline suggested:
+
+1. **Bearer JWT with a real expiry.** ``POST /Accounts/Login`` returns a token
+ and a ``validTo`` timestamp. The token is refreshed against that timestamp
+ rather than against an assumed lifetime, and once more on a 401 -- a clock
+ difference between us and the server should not end a backfill.
+2. **Bounded windows.** Readings endpoints take ``startTime``/``endTime`` in
+ Unix seconds and answer HTTP 500 when the span is too wide, so a fetch walks
+ windows and narrows on failure.
+3. **Datum is a request parameter, not a response field.** ``WaterLevels``
+ returns ``{dateAndTime, level}``; which datum that level is on depends on the
+ ``reference`` value sent. See ``GROUND_SURFACE_REFERENCE``.
+
+Credentials come from the environment and are never logged. The token is not
+logged either: it is a bearer credential for the whole account.
+"""
+
+import os
+import time
+from collections.abc import Iterator
+from dataclasses import dataclass
+from typing import Any, Protocol
+
+from automated_ingestion.shared.windows import DEFAULT_SPAN, Window, iter_windows
+
+BASE_URL = "https://diver-hub.com/private/api/v1"
+
+USERNAME_ENV_VAR = "DIVERHUB_USERNAME"
+PASSWORD_ENV_VAR = "DIVERHUB_PASSWORD"
+
+EXPIRY_SKEW_SECONDS = 60
+"""Refresh this long before ``validTo``, so a request in flight at the boundary
+does not arrive expired."""
+
+GROUND_SURFACE_REFERENCE: int | None = None
+"""Which ``WaterLevelReference`` value means depth below ground surface.
+
+The swagger declares the enum as ``[0, 1, 2, 3]`` with no names, so this cannot
+be derived from the specification -- it has to be observed against a well whose
+depth to water is independently known. Deliberately ``None`` until then:
+guessing wrong would not fail, it would silently record every reading on the
+wrong datum, which is the one error this pipeline must not make quietly.
+
+Set it from the finding of `scripts/probe_diverhub.py`.
+"""
+
+
+class Response(Protocol):
+ """The subset of a `requests` response this module uses."""
+
+ status_code: int
+
+ def json(self) -> Any: ...
+
+
+class Transport(Protocol):
+ """The subset of a `requests` session this module uses."""
+
+ def post(self, url: str, **kwargs: Any) -> Response: ...
+
+ def get(self, url: str, **kwargs: Any) -> Response: ...
+
+
+class DiverHubError(RuntimeError):
+ """The API refused a request in a way retrying will not fix."""
+
+
+@dataclass
+class _Token:
+ value: str
+ valid_to: float
+
+ def expired(self, now: float) -> bool:
+ return now >= self.valid_to - EXPIRY_SKEW_SECONDS
+
+
+class DiverHubClient:
+ """Authenticated, window-aware access to Diver-HUB."""
+
+ def __init__(
+ self,
+ transport: Transport,
+ username: str | None = None,
+ password: str | None = None,
+ base_url: str = BASE_URL,
+ timeout: int = 60,
+ ) -> None:
+ self._transport = transport
+ self._base_url = base_url.rstrip("/")
+ self._timeout = timeout
+ self._username = username or os.environ.get(USERNAME_ENV_VAR, "")
+ self._password = password or os.environ.get(PASSWORD_ENV_VAR, "")
+ self._token: _Token | None = None
+ if not self._username or not self._password:
+ raise DiverHubError(
+ f"Diver-HUB credentials are not set. Provide {USERNAME_ENV_VAR} and "
+ f"{PASSWORD_ENV_VAR} in the environment."
+ )
+
+ # -- authentication ----------------------------------------------------
+
+ def _login(self) -> _Token:
+ response = self._transport.post(
+ f"{self._base_url}/Accounts/Login",
+ json={"username": self._username, "password": self._password},
+ timeout=self._timeout,
+ )
+ if response.status_code == 401:
+ raise DiverHubError("Diver-HUB rejected the credentials.")
+ if response.status_code != 200:
+ raise DiverHubError(f"Login failed with HTTP {response.status_code}.")
+ payload = response.json()
+ return _Token(
+ value=payload["token"],
+ valid_to=_parse_timestamp(payload["validTo"]),
+ )
+
+ def _authorization(self) -> dict[str, str]:
+ if self._token is None or self._token.expired(time.time()):
+ self._token = self._login()
+ return {"Authorization": f"Bearer {self._token.value}"}
+
+ def _get(self, path: str, params: dict[str, Any] | None = None) -> Response:
+ """GET with one forced re-login if the token is rejected."""
+ response = self._transport.get(
+ f"{self._base_url}/{path.lstrip('/')}",
+ headers=self._authorization(),
+ params=params,
+ timeout=self._timeout,
+ )
+ if response.status_code == 401:
+ self._token = None
+ response = self._transport.get(
+ f"{self._base_url}/{path.lstrip('/')}",
+ headers=self._authorization(),
+ params=params,
+ timeout=self._timeout,
+ )
+ return response
+
+ # -- reference data ----------------------------------------------------
+
+ def projects(self) -> list[dict[str, Any]]:
+ """Projects visible to these credentials."""
+ return _expect_ok(self._get("Projects"), "Projects").json()
+
+ def monitoring_points(self, project_id: int) -> list[dict[str, Any]]:
+ """Monitoring points in a project. Returns ``{id, name}`` only --
+ no coordinates and no construction detail, so geometry and depth have
+ to be resolved from Ocotillo rather than from here."""
+ path = f"MonitoringPoints/ByProject/{project_id}"
+ return _expect_ok(self._get(path), path).json()
+
+ # -- series ------------------------------------------------------------
+
+ def water_levels(
+ self,
+ monitoring_point_id: int,
+ start: int,
+ end: int,
+ reference: int,
+ approved: bool | None = None,
+ span: int = DEFAULT_SPAN,
+ ) -> Iterator[dict[str, Any]]:
+ """Yield ``{dateAndTime, level}`` records across bounded windows.
+
+ ``reference`` selects the datum and is required: there is no safe
+ default, because the wrong value produces plausible numbers rather than
+ an error.
+ """
+ params: dict[str, Any] = {"reference": reference}
+ if approved is not None:
+ params["approved"] = approved
+ path = f"WaterLevels/ByMonitoringPoint/{monitoring_point_id}"
+ for window in iter_windows(start, end, span):
+ yield from self._fetch_window(path, window, params)
+
+ def diver_data(
+ self,
+ monitoring_point_id: int,
+ start: int,
+ end: int,
+ span: int = DEFAULT_SPAN,
+ ) -> Iterator[dict[str, Any]]:
+ """Yield raw ``DataPoint`` records -- pressure, temperature, and the
+ rest. Not water level; see ``water_levels`` for that."""
+ path = f"DiverData/ByMonitoringPoint/{monitoring_point_id}"
+ for window in iter_windows(start, end, span):
+ yield from self._fetch_window(path, window, {})
+
+ def _fetch_window(
+ self, path: str, window: Window, params: dict[str, Any]
+ ) -> Iterator[dict[str, Any]]:
+ """Fetch one window, halving it on a 500 until it succeeds or hits the
+ floor. A 500 here means "too much data", which is the API's way of
+ asking to be given a narrower range."""
+ response = self._get(
+ path, {**params, "startTime": window.start, "endTime": window.end}
+ )
+ if response.status_code == 500:
+ try:
+ left, right = window.bisect()
+ except ValueError as exc:
+ raise DiverHubError(
+ f"{path} returned HTTP 500 for {window.span}s starting "
+ f"{window.start}, which is already at the minimum window. "
+ "This is not a volume problem."
+ ) from exc
+ yield from self._fetch_window(path, left, params)
+ yield from self._fetch_window(path, right, params)
+ return
+ yield from _expect_ok(response, path).json()
+
+
+def _expect_ok(response: Response, what: str) -> Response:
+ if response.status_code != 200:
+ raise DiverHubError(f"{what} returned HTTP {response.status_code}.")
+ return response
+
+
+def _parse_timestamp(value: str) -> float:
+ """Parse an ISO-8601 instant into a Unix timestamp.
+
+ The API reports UTC but does not always mark it, so a naive value is read
+ as UTC rather than as local time -- reading it as local would shift token
+ expiry by the machine's offset and, worse, shift every reading.
+ """
+ from datetime import datetime, timezone
+
+ text = value.replace("Z", "+00:00")
+ parsed = datetime.fromisoformat(text)
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.timestamp()
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/sources/san_acacia/transform.py b/automated_ingestion/sources/san_acacia/transform.py
index 7ed027ff1..4630ae8a2 100644
--- a/automated_ingestion/sources/san_acacia/transform.py
+++ b/automated_ingestion/sources/san_acacia/transform.py
@@ -14,10 +14,17 @@
# limitations under the License.
# ===============================================================================
"""
-Van Essen payload reshaping that precedes adaptation.
+Reshaping that precedes adaptation.
-Van Essen returns parallel arrays rather than one object per reading, so this
-is where they become records.
+Less is needed here than the plan first assumed. The retired FROST pipeline
+suggested Van Essen returned parallel arrays that had to be zipped into
+records; the live API returns ``[{dateAndTime, level}]`` already, and selects
+datum and approval through query parameters rather than through which array a
+value came from.
+
+What remains for this module is timestamp normalisation and whatever
+per-record tidying the live responses turn out to need. Filled in under BDMS
+task 3.1, once the probe has run.
"""
# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_diverhub_client.py b/automated_ingestion/tests/test_diverhub_client.py
new file mode 100644
index 000000000..313e2eab7
--- /dev/null
+++ b/automated_ingestion/tests/test_diverhub_client.py
@@ -0,0 +1,177 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Client behaviour that is easy to get wrong and expensive to get wrong:
+token refresh, the 401 retry, and narrowing on a 500.
+
+No network. The transport is a stub that records what it was asked for.
+"""
+
+import pytest
+
+from automated_ingestion.shared.windows import DAY
+from automated_ingestion.sources.san_acacia.client import (
+ DiverHubClient,
+ DiverHubError,
+)
+
+
+class FakeResponse:
+ def __init__(self, status_code=200, payload=None):
+ self.status_code = status_code
+ self._payload = payload if payload is not None else []
+
+ def json(self):
+ return self._payload
+
+
+class FakeTransport:
+ """Records calls and replays queued responses."""
+
+ def __init__(self, get_responses=None, token_valid_for=3600):
+ self.posts = []
+ self.gets = []
+ self._get_responses = list(get_responses or [])
+ self._token_valid_for = token_valid_for
+ self.login_count = 0
+
+ def post(self, url, **kwargs):
+ self.posts.append((url, kwargs))
+ self.login_count += 1
+ from datetime import datetime, timedelta, timezone
+
+ valid_to = datetime.now(tz=timezone.utc) + timedelta(
+ seconds=self._token_valid_for
+ )
+ return FakeResponse(
+ 200,
+ {"token": f"token-{self.login_count}", "validTo": valid_to.isoformat()},
+ )
+
+ def get(self, url, **kwargs):
+ self.gets.append((url, kwargs))
+ if self._get_responses:
+ return self._get_responses.pop(0)
+ return FakeResponse(200, [])
+
+
+def _client(transport):
+ return DiverHubClient(transport, username="u", password="p")
+
+
+def test_missing_credentials_fail_fast(monkeypatch):
+ monkeypatch.delenv("DIVERHUB_USERNAME", raising=False)
+ monkeypatch.delenv("DIVERHUB_PASSWORD", raising=False)
+ with pytest.raises(DiverHubError, match="credentials"):
+ DiverHubClient(FakeTransport())
+
+
+def test_token_is_reused_across_calls():
+ transport = FakeTransport()
+ client = _client(transport)
+ client.projects()
+ client.projects()
+ assert transport.login_count == 1
+
+
+def test_token_is_refreshed_once_expired():
+ # validTo in the past means every call re-authenticates.
+ transport = FakeTransport(token_valid_for=-10)
+ client = _client(transport)
+ client.projects()
+ client.projects()
+ assert transport.login_count == 2
+
+
+def test_expiry_skew_refreshes_before_the_deadline():
+ # A token valid for 30s is already inside the skew window, so it must not
+ # be used: a request in flight at the boundary would arrive expired.
+ transport = FakeTransport(token_valid_for=30)
+ client = _client(transport)
+ client.projects()
+ client.projects()
+ assert transport.login_count == 2
+
+
+def test_401_forces_one_reauthentication_and_retry():
+ transport = FakeTransport(
+ get_responses=[FakeResponse(401), FakeResponse(200, [{"id": 1}])]
+ )
+ client = _client(transport)
+ assert client.projects() == [{"id": 1}]
+ assert transport.login_count == 2
+ assert len(transport.gets) == 2
+
+
+def test_500_narrows_the_window_and_stitches_the_halves():
+ # First window 500s; each half then succeeds and both are returned.
+ transport = FakeTransport(
+ get_responses=[
+ FakeResponse(500),
+ FakeResponse(200, [{"level": 1.0}]),
+ FakeResponse(200, [{"level": 2.0}]),
+ ]
+ )
+ client = _client(transport)
+ rows = list(client.water_levels(40, 0, 100 * DAY, reference=0, span=100 * DAY))
+ assert [r["level"] for r in rows] == [1.0, 2.0]
+
+
+def test_persistent_500_at_the_floor_is_an_error_not_a_loop():
+ transport = FakeTransport(get_responses=[FakeResponse(500)] * 50)
+ client = _client(transport)
+ with pytest.raises(DiverHubError, match="not a volume problem"):
+ list(client.water_levels(40, 0, DAY, reference=0, span=DAY))
+
+
+def test_water_levels_sends_reference_and_unix_seconds():
+ transport = FakeTransport()
+ client = _client(transport)
+ list(client.water_levels(40, 0, DAY, reference=2, span=DAY))
+ _, kwargs = transport.gets[0]
+ params = kwargs["params"]
+ assert params["reference"] == 2
+ assert params["startTime"] == 0
+ assert params["endTime"] == DAY
+ assert isinstance(params["startTime"], int)
+
+
+def test_approved_is_omitted_unless_asked_for():
+ transport = FakeTransport()
+ client = _client(transport)
+ list(client.water_levels(40, 0, DAY, reference=0, span=DAY))
+ assert "approved" not in transport.gets[0][1]["params"]
+
+
+def test_naive_valid_to_is_read_as_utc():
+ # The API documents UTC but does not always mark it. Reading a naive
+ # timestamp as local time would shift expiry by the machine's offset.
+ from automated_ingestion.sources.san_acacia.client import _parse_timestamp
+
+ naive = _parse_timestamp("2026-08-18T20:00:00")
+ aware = _parse_timestamp("2026-08-18T20:00:00Z")
+ assert naive == aware
+
+
+def test_ground_surface_reference_is_unset_until_confirmed():
+ # Guessing would not fail loudly; it would record every reading on the
+ # wrong datum. The constant stays None until someone observes it.
+ from automated_ingestion.sources.san_acacia import client as module
+
+ assert module.GROUND_SURFACE_REFERENCE is None
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_windows.py b/automated_ingestion/tests/test_windows.py
new file mode 100644
index 000000000..34d68602d
--- /dev/null
+++ b/automated_ingestion/tests/test_windows.py
@@ -0,0 +1,71 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Window arithmetic, including the refusal to shrink past the floor."""
+
+import pytest
+
+from automated_ingestion.shared.windows import (
+ DAY,
+ MINIMUM_SPAN,
+ Window,
+ iter_windows,
+)
+
+
+def test_windows_cover_the_range_without_gaps_or_overlap():
+ windows = list(iter_windows(0, 10 * DAY, span=3 * DAY))
+ assert windows[0].start == 0
+ assert windows[-1].end == 10 * DAY
+ for earlier, later in zip(windows, windows[1:]):
+ assert earlier.end == later.start
+
+
+def test_final_window_is_truncated_not_overshot():
+ # Overshooting would ask the API for a future range, which is at best waste
+ # and at worst a 400.
+ windows = list(iter_windows(0, 10 * DAY, span=3 * DAY))
+ assert windows[-1].end == 10 * DAY
+ assert windows[-1].span == DAY
+
+
+def test_range_shorter_than_span_is_a_single_window():
+ assert list(iter_windows(0, DAY, span=90 * DAY)) == [Window(0, DAY)]
+
+
+def test_empty_range_yields_nothing():
+ assert list(iter_windows(500, 500)) == []
+
+
+def test_reversed_range_is_rejected():
+ with pytest.raises(ValueError, match="precedes"):
+ list(iter_windows(10, 5))
+
+
+def test_bisect_splits_in_half():
+ left, right = Window(0, 100 * DAY).bisect()
+ assert left.start == 0
+ assert left.end == right.start
+ assert right.end == 100 * DAY
+
+
+def test_bisect_refuses_below_the_floor():
+ # A 500 on one day is not a volume problem, and silently halving forever
+ # would turn one real failure into an unbounded pile of requests.
+ with pytest.raises(ValueError, match="floor"):
+ Window(0, MINIMUM_SPAN).bisect()
+
+
+# ============= EOF =============================================
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index 3d72d1329..0cd7f8704 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -194,17 +194,24 @@ Land locations and readings untransformed in GCS as date-partitioned parquet. Ra
### 2.1 — Confirm the readings endpoint; finalize the source mapping
-**Unblocked.** The endpoint works; the 500s were a wrong path plus an oversized window (see Blocker). Mapping details inferred from retired FROST data can now be checked against live responses and against the Diver-HUB swagger.
+**The swagger is public** (`https://diver-hub.com/private/swagger/v1/swagger.json`) and reading it settled most of this without credentials. Full mapping in `docs/sources/san_acacia.md`; four corrections that invalidate parts of the original draft:
-- Authenticate: POST credentials to the login endpoint, hold the 1-hour JWT, re-acquire on expiry or on a 401. Credentials and token never committed and never logged.
-- Measure the window ceiling. Three months is known-good; find where it breaks so the chunk size is chosen rather than guessed. Record the number here.
-- Confirmed against live responses: `ts` format and timezone; `gs` unit is feet; `approvedWaterLevelsGs` and `unApprovedWaterLevelsGs` are the complete, non-overlapping set; whether `groundSurfaceData` elevation is needed and how it's time-scoped.
-- Reconcile the swagger against `docs/sources/san_acacia.md`: the locations endpoint and the `/api/api/` doubled segment were both taken from the old assumption and may not survive.
-- `drillingDepth` centimetres (÷ 30.48) confirmed, not back-calculated.
-- Fixture responses committed for tests, credentials scrubbed.
-- `docs/sources/san_acacia.md` copied into OcotilloAPI and corrected.
+- ✅ **No `/api/api/` segment, no `locations/sanacaciareach`.** Seven endpoints under `/api/v1/`. Reference data is `Projects` → `MonitoringPoints/ByProject/{id}`.
+- ✅ **No `gs`/`vrd` arrays.** `WaterLevels/ByMonitoringPoint` returns a flat `[{dateAndTime, level}]`. Datum and approval are *query parameters* (`reference`, `approved`), not fields to pick out of parallel arrays. The reshaping `transform.py` was scaffolded for does not exist.
+- ✅ **`DiverData` is not the series we want.** It returns `DataPoint` — pressure, temperature, conductivity, salinity — with no water level. It is what the known-good example URL fetches, which is why it looked like the readings endpoint.
+- ✅ **`MonitoringPoint` is `{id, name}` only.** No coordinates, no `drillingDepth`. The planned centimetre conversion and geometry mapping have no source here; both must come from the Ocotillo rows the points reconcile against.
-Datum and vendor-flag questions are already settled in the Epic — `vrd` is not ingested, and the vendor flag does not map to `review_status`.
+Built, and testable without the network:
+
+- ✅ `sources/san_acacia/client.py` — JWT auth refreshed against `validTo` with a skew, one forced re-login on a 401, and windowed fetches that halve on a 500 and refuse to shrink past a one-day floor.
+- ✅ `shared/windows.py` — the window arithmetic, kept pure so the tricky part is testable.
+- ✅ `scripts/probe_diverhub.py` — a one-off instrument that answers the remaining questions against the live API.
+
+⬜ **Run the probe.** It needs the credentials Ethan circulated. Until then:
+
+**`WaterLevelReference` is `enum [0,1,2,3]` with no names in the spec, and this is the highest-risk unknown in the epic.** Which value means ground surface is not derivable, and choosing wrong does not fail — it returns plausible numbers on the wrong datum and silently poisons every reading. `GROUND_SURFACE_REFERENCE` is `None` in code and the client will not guess. The probe samples all four side by side so a person can identify it against a well whose depth to water is known.
+
+Also still open: the window ceiling (three months works, the limit is unmeasured), whether `approved=true`/`false` partition or overlap, whether `dateAndTime` is marked UTC, and whether `level` is feet. That last one gates correctness rather than completeness, same as the datum.
### 2.2 — dlt resource: locations → GCS
diff --git a/docs/sources/san_acacia.md b/docs/sources/san_acacia.md
new file mode 100644
index 000000000..0dc0c77cd
--- /dev/null
+++ b/docs/sources/san_acacia.md
@@ -0,0 +1,150 @@
+# Source: San Acacia Reach (Van Essen divers, Diver-HUB)
+
+The pilot source for automated ingestion: 33 monitoring points, one
+depth-to-groundwater series each. Historically flowed through the retired
+FROST/`st2` stack; now flows nowhere.
+
+This document supersedes the mapping in `Aqueduct/docs/sources/san_acacia.md`,
+which described the FROST-era payload rather than the live API.
+
+## API
+
+Base URL `https://diver-hub.com/private/api/v1`.
+Specification: `https://diver-hub.com/private/swagger/v1/swagger.json` — public,
+no authentication needed to read it. **Treat the swagger as authoritative over
+anything inherited from the FROST pipeline.**
+
+There is no `/api/api/` doubled path segment and no `locations/sanacaciareach`
+endpoint. Both appeared in earlier drafts and neither exists.
+
+| Endpoint | Returns | Used for |
+|---|---|---|
+| `POST /Accounts/Login` | `{token, validTo}` | Authentication |
+| `GET /Projects` | `[{id, name}]` | Finding the San Acacia project id |
+| `GET /MonitoringPoints/ByProject/{projectId}` | `[{id, name}]` | The 33 points |
+| `GET /WaterLevels/ByMonitoringPoint/{id}` | `[{dateAndTime, level}]` | **The series we ingest** |
+| `GET /DiverData/ByMonitoringPoint/{id}` | `[DataPoint]` | Raw sensor output; not ingested |
+| `GET /ManualMeasurements/ByMonitoringPoint/{id}` | `[{dateAndTime, waterLevelToc}]` | Not ingested — see below |
+| `GET /WeatherStationData/AirPressure/ByMonitoringPoint/{id}` | `[DataPoint]` | Not ingested |
+
+### Authentication
+
+`POST /Accounts/Login` with `{username, password}` returns a bearer JWT and a
+`validTo` timestamp. Every other endpoint requires
+`Authorization: Bearer {token}` and answers `401` without it.
+
+The token is short-lived — about an hour. Refresh against `validTo` rather than
+against an assumed lifetime, with a skew so a request in flight at the boundary
+does not arrive expired, and re-authenticate once on a `401` so a clock
+difference cannot end a backfill. Implemented in
+`automated_ingestion/sources/san_acacia/client.py`.
+
+Credentials live in Secret Manager, never in GitHub secrets and never in the
+repository. They are read from `DIVERHUB_USERNAME` / `DIVERHUB_PASSWORD`.
+
+### Windowing
+
+`WaterLevels`, `DiverData`, `ManualMeasurements` and `AirPressure` all take
+`startTime` and `endTime` as **Unix seconds, UTC**, inclusive of both ends.
+
+An oversized span returns **HTTP 500** — not a 413, not a pagination cursor.
+The 500s that stalled this work were this, not a vendor outage. A
+confirmed-good request:
+
+```
+GET /DiverData/ByMonitoringPoint/40?startTime=1767225600&endTime=1775001600
+```
+
+That is roughly 1 Jan – 1 Apr. Consequently a fetch is always a sequence of
+bounded windows, and the right response to a 500 is to halve the window and
+retry rather than to mark the entity failed. This applies to the daily
+incremental run too, not only to backfill: an entity whose cursor has fallen
+months behind hits the same ceiling. See `automated_ingestion/shared/windows.py`.
+
+**The exact ceiling is unmeasured.** Three months works. `probe_diverhub.py`
+widens until it breaks; record the result here when it has been run.
+
+## Field mapping
+
+### Water levels — the ingested series
+
+`WaterLevel` is `{dateAndTime: date-time, level: double}`. That is the whole
+schema. Two consequences worth stating plainly, because earlier drafts assumed
+otherwise:
+
+- **There are no `gs` / `vrd` arrays**, and no `approvedWaterLevelsGs` /
+ `unApprovedWaterLevelsGs`. Nothing in the response says which datum `level`
+ is on or whether the vendor approved it.
+- **Datum and approval are request parameters.** `reference` selects the datum;
+ `approved` (boolean) selects the vendor's approval state. The same point and
+ time range returns different numbers depending on what was asked for.
+
+### WaterLevelReference — unresolved, and the highest-risk unknown here
+
+The swagger declares:
+
+```json
+"WaterLevelReference": { "enum": [0, 1, 2, 3] }
+```
+
+No names, no descriptions. **Which value means depth below ground surface
+cannot be determined from the specification.**
+
+This matters more than the other open questions because getting it wrong does
+not fail. It returns plausible numbers on the wrong datum, and every ingested
+reading is silently wrong. `GROUND_SURFACE_REFERENCE` in `client.py` is
+therefore `None`, and the pipeline refuses to guess.
+
+Resolve it by running `probe_diverhub.py`, which samples all four values for
+one point side by side, and comparing against a well whose depth to water is
+independently known. Record the answer here and set the constant.
+
+### Monitoring points — thinner than expected
+
+`MonitoringPoint` is `{id: int, name: string}`. **No coordinates, no
+`drillingDepth`, no construction detail.**
+
+So the planned `drillingDepth` centimetre conversion (÷ 30.48) has no source in
+this API, and neither does geometry. Both have to come from the Ocotillo
+`Thing` and `Location` records the points reconcile against. That is consistent
+with the decision that ingestion never creates wells: matching to an existing
+row is the only way it learns where a point is.
+
+### Not ingested
+
+- **`DiverData`** returns `DataPoint` — `pressure`, `temperature`,
+ `conductivity`, `salinity`, `airPressure`, `precipitation`. Useful for
+ diagnostics, and it is what the known-good example URL fetches, but it
+ contains no water level.
+- **`ManualMeasurements`** returns `waterLevelToc` — top of casing. Ocotillo's
+ manual-measurement path already owns this, and mixing a TOC-referenced series
+ into a ground-surface one is the datum error above by another route.
+- **`AirPressure`** matters only for barometric compensation, which is the
+ Hydrograph Corrector's job downstream.
+
+## Decisions inherited from the epic
+
+Settled, not to be relitigated per source:
+
+- **Ground-surface datum.** Never `vrd`, never TOC. No measuring-point
+ correction on ingest.
+- **Public but provisional.** Visible from the first run, marked so no consumer
+ mistakes an uncorrected diver series for a reviewed one.
+- **The vendor `approved` flag is not Ocotillo `review_status`.** Ocotillo's
+ `approved` asserts a *Bureau* human reviewed it and carries a `reviewer_id`
+ FK. All San Acacia blocks land `not reviewed`; the vendor flag is preserved
+ as a separate per-row attribute.
+
+## Open questions
+
+| # | Question | How to settle |
+|---|---|---|
+| 1 | Which `reference` value is ground surface? | `probe_diverhub.py`, compared against a known well |
+| 2 | What is the window ceiling? | `probe_diverhub.py` widens until 500 |
+| 3 | Which project id holds San Acacia, and is it really 33 points? | `probe_diverhub.py` |
+| 4 | Do `approved=true` and `approved=false` partition the series, or overlap? | Fetch both for one window and compare timestamps |
+| 5 | Is `dateAndTime` UTC in the response, and is it marked as such? | Inspect a live payload |
+| 6 | Is `level` in feet? | Compare against a known measurement |
+
+Questions 1 and 6 both gate correctness rather than completeness: wrong answers
+produce data that looks fine.
From 6166171dedf8d0fc4414cca804569a0fa738a790 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 14:14:35 -0700
Subject: [PATCH 069/151] docs(ingestion): record the probe findings and
correct the 500 model
Three things the live API settled, one of which reverses what this document
previously asserted.
The 500 is not a volume ceiling. WaterLevels -- the endpoint we actually ingest
-- served 730 days and 18111 rows in a single request, and a 30-day window slid
back three years never failed. DiverData is what breaks, and bisecting it ten
times to a 17-hour window still returned 500, so volume is not the trigger
there either.
The project is 4317 and holds 38 points, not the 33 the plan assumes. Five are
unaccounted for. Since ingestion matches wells rather than creating them, that
is a question for a person before 3.2 reconciles anything.
All four reference values return the same rows at the same timestamps, offset
by constants, so they are one series against four datums. reference=2 is an
elevation. Which of the rest is ground surface is still open: min/max cannot
distinguish an inversion from an offset, so the probe now compares aligned rows
and checks them against ManualMeasurements, which reports top of casing
explicitly.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/scripts/probe_diverhub.py | 214 +++++++++++++++---
.../sources/san_acacia/client.py | 13 ++
docs/sources/san_acacia.md | 103 ++++++---
3 files changed, 262 insertions(+), 68 deletions(-)
diff --git a/automated_ingestion/scripts/probe_diverhub.py b/automated_ingestion/scripts/probe_diverhub.py
index 624b28f2d..c033d4e23 100644
--- a/automated_ingestion/scripts/probe_diverhub.py
+++ b/automated_ingestion/scripts/probe_diverhub.py
@@ -70,6 +70,13 @@ def probe_points(client: DiverHubClient, project_id: int) -> list[dict]:
print(f"\n== Monitoring points in project {project_id} ==")
points = client.monitoring_points(project_id)
print(f" {len(points)} points (the plan expects 33)")
+ if len(points) != 33:
+ print(
+ " ^ count differs from the plan; listing all so the extras\n can be identified before anything is reconciled."
+ )
+ for point in points:
+ print(f" {point['id']:>6} {point['name']}")
+ return points
for point in points[:5]:
print(f" {point['id']:>6} {point['name']}")
if len(points) > 5:
@@ -77,44 +84,182 @@ def probe_points(client: DiverHubClient, project_id: int) -> list[dict]:
return points
-def probe_reference_values(client: DiverHubClient, point_id: int, end: int) -> None:
- """Sample each reference value so a human can tell which is ground surface."""
- print(f"\n== WaterLevelReference values for point {point_id} ==")
- print(" Ground surface should read as depth below ground: positive and")
- print(" plausible as feet-below-surface for this well. An elevation will")
- print(" look like a much larger number.\n")
- start = end - 30 * DAY
- for reference in REFERENCE_VALUES:
- try:
- rows = list(client.water_levels(point_id, start, end, reference=reference))
- except DiverHubError as exc:
- print(f" reference={reference}: error -- {exc}")
- continue
- if not rows:
- print(f" reference={reference}: no rows in the last 30 days")
- continue
- levels = [r["level"] for r in rows if r.get("level") is not None]
- sample = rows[0]
- print(
- f" reference={reference}: {len(rows):>6} rows, "
- f"min={min(levels):.3f} max={max(levels):.3f} "
- f"first={sample.get('dateAndTime')} level={sample.get('level')}"
- )
+def probe_reference_values(
+ client: DiverHubClient, points: list[dict], end: int
+) -> None:
+ """Sample each reference value so a human can tell which is ground surface.
+
+ Searches over a year, and moves on to another point if the first has gone
+ quiet -- a diver that stopped reporting months ago tells us nothing about
+ what the enum means.
+ """
+ print("\n== WaterLevelReference values ==")
+ print(" Ground surface reads as depth below ground: positive, and")
+ print(" plausible as feet below surface. An elevation is a much larger")
+ print(" number. A vrd/TOC series looks like ground surface but is offset")
+ print(" by the stickup, so compare against a well you know.\n")
+
+ start = end - 365 * DAY
+ for point in points[:6]:
+ point_id, name = point["id"], point["name"]
+ found = False
+ for reference in REFERENCE_VALUES:
+ try:
+ rows = list(
+ client.water_levels(point_id, start, end, reference=reference)
+ )
+ except DiverHubError as exc:
+ print(f" {name} reference={reference}: error -- {exc}")
+ continue
+ if not rows:
+ print(f" {name} reference={reference}: no rows in 365d")
+ continue
+ found = True
+ levels = [r["level"] for r in rows if r.get("level") is not None]
+ print(
+ f" {name} reference={reference}: {len(rows):>5} rows, "
+ f"min={min(levels):>10.3f} max={max(levels):>10.3f} "
+ f"first={rows[0].get('dateAndTime')} last={rows[-1].get('dateAndTime')}"
+ )
+ if found:
+ print(f"\n ^ compare these four for {name} and pick the datum.")
+ return
+ print(" No point returned water levels in the last year.")
def probe_window_ceiling(client: DiverHubClient, point_id: int, end: int) -> None:
- """Widen until the API breaks, so the production span is a measurement."""
- print(f"\n== Window ceiling for point {point_id} ==")
- for days in (90, 180, 365, 730, 1825):
+ """Find what actually triggers a 500.
+
+ Widening from the present tests span. Sliding a fixed narrow window back
+ through time tests whether the failure is instead about *when* -- a range
+ that predates the point's data. The two look identical from the status
+ code, so both are worth separating here.
+ """
+ print(f"\n== Window behaviour for point {point_id} ==")
+ print(" Widening back from now (tests span):")
+ for days in (90, 180, 365, 545, 730):
start = end - days * DAY
try:
- rows = list(client.diver_data(point_id, start, end, span=days * DAY))
- print(f" {days:>5}d ({_iso(start)}): ok, {len(rows)} rows")
- except DiverHubError as exc:
- print(f" {days:>5}d: FAILED -- {exc}")
- print(" ^ ceiling is below this; use the last successful span.")
- return
- print(" No ceiling found up to 5 years.")
+ rows = list(
+ client.water_levels(
+ point_id,
+ start,
+ end,
+ reference=REFERENCE_VALUES[0],
+ span=days * DAY,
+ )
+ )
+ print(f" {days:>5}d: ok, {len(rows)} rows")
+ except DiverHubError:
+ print(f" {days:>5}d: 500 even at the one-day floor")
+
+ print(" Fixed 30-day window slid backwards (tests age, not span):")
+ for years_back in (0, 1, 2, 3):
+ window_end = end - years_back * 365 * DAY
+ window_start = window_end - 30 * DAY
+ label = f"{years_back}y ago"
+ try:
+ rows = list(
+ client.water_levels(
+ point_id,
+ window_start,
+ window_end,
+ reference=REFERENCE_VALUES[0],
+ span=30 * DAY,
+ )
+ )
+ print(f" {label:>8}: ok, {len(rows)} rows")
+ except DiverHubError:
+ print(f" {label:>8}: 500 at the floor")
+
+
+def probe_datum_relationships(
+ client: DiverHubClient, point_id: int, name: str, start: int, end: int
+) -> None:
+ """Settle what the four reference values mean, using the API against itself.
+
+ Two questions the min/max summary cannot answer:
+
+ 1. **Is any of them an elevation rather than a depth?** An elevation moves
+ opposite to a depth, so ``elevation + depth`` is constant while
+ ``depth - depth`` is constant. Comparing aligned rows distinguishes them;
+ comparing ranges does not, because both look like the same spread.
+ 2. **Which is ground surface?** ``ManualMeasurements`` reports
+ ``waterLevelToc`` -- explicitly top of casing. Whichever reference tracks
+ it *is* the TOC series, and ground surface is then the one shallower than
+ it by the casing stickup.
+ """
+ print(f"\n== Datum relationships for {name} ==")
+ series: dict[int, dict[str, float]] = {}
+ for reference in REFERENCE_VALUES:
+ rows = list(client.water_levels(point_id, start, end, reference=reference))
+ series[reference] = {
+ r["dateAndTime"]: r["level"] for r in rows if r.get("level") is not None
+ }
+
+ shared = set.intersection(*(set(v) for v in series.values())) if series else set()
+ stamps = sorted(shared)[:3]
+ if not stamps:
+ print(" No overlapping timestamps across references.")
+ return
+
+ print(" Aligned samples:")
+ print(f" {'timestamp':<22}" + "".join(f"ref{r:<14}" for r in REFERENCE_VALUES))
+ for stamp in stamps:
+ cells = "".join(f"{series[r][stamp]:<17.3f}" for r in REFERENCE_VALUES)
+ print(f" {stamp:<22}{cells}")
+
+ base = REFERENCE_VALUES[0]
+ print(f"\n Relationship to ref={base} across those samples:")
+ for reference in REFERENCE_VALUES[1:]:
+ diffs = {round(series[reference][t] - series[base][t], 3) for t in stamps}
+ sums = {round(series[reference][t] + series[base][t], 3) for t in stamps}
+ if len(diffs) == 1:
+ print(
+ f" ref={reference}: constant OFFSET {diffs.pop():+.3f} "
+ "-- same direction, so also a depth"
+ )
+ elif len(sums) == 1:
+ print(
+ f" ref={reference}: constant SUM {sums.pop():.3f} "
+ "-- INVERTED, so this one is an elevation"
+ )
+ else:
+ print(f" ref={reference}: neither constant; not a simple datum shift")
+
+ print("\n Manual measurements (waterLevelToc = top of casing):")
+ try:
+ manual = client.manual_measurements(point_id, start, end)
+ except DiverHubError as exc:
+ print(f" unavailable -- {exc}")
+ return
+ if not manual:
+ print(" none in this window; try a wider one.")
+ return
+ for record in manual[:3]:
+ stamp = record.get("dateAndTime")
+ toc = record.get("waterLevelToc")
+ print(f" {stamp} toc={toc}")
+ nearest = min(stamps, key=lambda t: abs(_epoch(t) - _epoch(stamp)))
+ print(f" nearest logged sample {nearest}:")
+ for reference in REFERENCE_VALUES:
+ delta = series[reference][nearest] - toc if toc is not None else None
+ if delta is not None:
+ print(
+ f" ref={reference}: {series[reference][nearest]:.3f} "
+ f"(toc{delta:+.3f})"
+ )
+ print("\n The reference nearest zero against toc IS the TOC series.")
+ print(" Ground surface is shallower than TOC by the casing stickup.")
+
+
+def _epoch(stamp: str) -> float:
+ from datetime import datetime, timezone
+
+ parsed = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.timestamp()
def main() -> int:
@@ -140,8 +285,9 @@ def main() -> int:
return 1
point_id = points[0]["id"]
- probe_reference_values(client, point_id, end)
+ probe_reference_values(client, points, end)
probe_window_ceiling(client, point_id, end)
+ probe_datum_relationships(client, point_id, points[0]["name"], end - 730 * DAY, end)
print("\nRecord the findings in docs/sources/san_acacia.md and set")
print("GROUND_SURFACE_REFERENCE in sources/san_acacia/client.py.")
diff --git a/automated_ingestion/sources/san_acacia/client.py b/automated_ingestion/sources/san_acacia/client.py
index 05aeb248d..8b70ab1f5 100644
--- a/automated_ingestion/sources/san_acacia/client.py
+++ b/automated_ingestion/sources/san_acacia/client.py
@@ -170,6 +170,19 @@ def monitoring_points(self, project_id: int) -> list[dict[str, Any]]:
path = f"MonitoringPoints/ByProject/{project_id}"
return _expect_ok(self._get(path), path).json()
+ def manual_measurements(
+ self, monitoring_point_id: int, start: int, end: int
+ ) -> list[dict[str, Any]]:
+ """Manual readings, reported against top of casing.
+
+ Not ingested -- Ocotillo's manual-measurement path owns these. Fetched
+ only to identify which ``reference`` value is the TOC series, since the
+ swagger names the enum members not at all.
+ """
+ path = f"ManualMeasurements/ByMonitoringPoint/{monitoring_point_id}"
+ response = self._get(path, {"startTime": start, "endTime": end})
+ return _expect_ok(response, path).json()
+
# -- series ------------------------------------------------------------
def water_levels(
diff --git a/docs/sources/san_acacia.md b/docs/sources/san_acacia.md
index 0dc0c77cd..79ea05f94 100644
--- a/docs/sources/san_acacia.md
+++ b/docs/sources/san_acacia.md
@@ -1,7 +1,10 @@
# Source: San Acacia Reach (Van Essen divers, Diver-HUB)
-The pilot source for automated ingestion: 33 monitoring points, one
-depth-to-groundwater series each. Historically flowed through the retired
+The pilot source for automated ingestion. Project **4317 `SanAcaciaReach`**,
+containing **38 monitoring points** named `SO-####` — the plan and the Aqueduct
+mapping both say 33, so five are unaccounted for and must be identified before
+3.2 reconciles anything. Ingestion never creates wells, so an unexpected point
+is a decision, not a row. Historically flowed through the retired
FROST/`st2` stack; now flows nowhere.
This document supersedes the mapping in `Aqueduct/docs/sources/san_acacia.md`,
@@ -42,27 +45,37 @@ difference cannot end a backfill. Implemented in
Credentials live in Secret Manager, never in GitHub secrets and never in the
repository. They are read from `DIVERHUB_USERNAME` / `DIVERHUB_PASSWORD`.
-### Windowing
+### Windowing — measured 2026-08-18
-`WaterLevels`, `DiverData`, `ManualMeasurements` and `AirPressure` all take
-`startTime` and `endTime` as **Unix seconds, UTC**, inclusive of both ends.
+All series endpoints take `startTime` and `endTime` as **Unix seconds, UTC**,
+inclusive of both ends.
-An oversized span returns **HTTP 500** — not a 413, not a pagination cursor.
-The 500s that stalled this work were this, not a vendor outage. A
-confirmed-good request:
+**The 500 is endpoint-specific, and `WaterLevels` — the endpoint we ingest —
+did not exhibit it.** Measured against point 39 (SO-0125):
-```
-GET /DiverData/ByMonitoringPoint/40?startTime=1767225600&endTime=1775001600
-```
+| Span back from now | `WaterLevels` |
+|---|---|
+| 90 d | ok, 0 rows |
+| 180 d | ok, 1054 rows |
+| 365 d | ok, 1054 rows |
+| 545 d | ok, 9302 rows |
+| 730 d | ok, **18111 rows** |
-That is roughly 1 Jan – 1 Apr. Consequently a fetch is always a sequence of
-bounded windows, and the right response to a 500 is to halve the window and
-retry rather than to mark the entity failed. This applies to the daily
-incremental run too, not only to backfill: an entity whose cursor has fallen
-months behind hits the same ceiling. See `automated_ingestion/shared/windows.py`.
+A fixed 30-day window slid back 0/1/2/3 years also succeeded every time, so
+there is no age-based cutoff on this endpoint either.
-**The exact ceiling is unmeasured.** Three months works. `probe_diverhub.py`
-widens until it breaks; record the result here when it has been run.
+`DiverData` is a different story: a 730-day request failed, and bisecting it
+ten times down to a **17-hour** window still returned 500. That is not a volume
+ceiling — a 17-hour window of raw diver data is trivial. The failing slice was
+the oldest part of the range, starting 2024-08-18. Whatever the cause, it is
+specific to `DiverData`, which we do not ingest.
+
+Practical consequence: the windowing machinery in
+`automated_ingestion/shared/windows.py` stays, because 18111 rows in one
+response is already large and the ceiling is untested above 730 days, but the
+halve-on-500 recovery is **not** a routine path for `WaterLevels`. Do not
+assume a 500 there means "too much data" without re-measuring; on `DiverData`
+that assumption is provably wrong.
## Field mapping
@@ -79,25 +92,47 @@ otherwise:
`approved` (boolean) selects the vendor's approval state. The same point and
time range returns different numbers depending on what was asked for.
-### WaterLevelReference — unresolved, and the highest-risk unknown here
+### WaterLevelReference — measured, not yet decided
+
+The swagger declares `"WaterLevelReference": { "enum": [0, 1, 2, 3] }` with no
+names and no descriptions, so the meaning cannot be read off the spec.
+
+Sampled for SO-0125 over 365 days — all four return **the same 1054 rows at the
+same timestamps**, differing only by a constant offset. They are one series
+expressed against four datums:
+
+| `reference` | min | max | offset vs 0 |
+|---|---|---|---|
+| 0 | 199.356 | 250.697 | — |
+| 1 | 267.462 | 318.804 | +68.11 |
+| 2 | 139200.653 | 139251.994 | +139001.30 |
+| 3 | 222.005 | 273.347 | +22.65 |
-The swagger declares:
+Spread is identical to three decimals (51.34) across all four, confirming they
+are the same measurements re-referenced.
-```json
-"WaterLevelReference": { "enum": [0, 1, 2, 3] }
-```
+**`reference=2` is an elevation, not a depth.** It is three orders of magnitude
+larger than the others. Read as centimetres it is 1392 m ≈ 4567 ft, which
+matches San Acacia's ground elevation — which in turn implies the unit
+throughout is **centimetres**, making 0/1/3 read as roughly 2–3 m depths.
+That is plausible for riparian piezometers and implausible as feet, but it is
+inference from one well, not a confirmed unit.
-No names, no descriptions. **Which value means depth below ground surface
-cannot be determined from the specification.**
+**Which of 0, 1, 3 is ground surface is still undecided**, and min/max cannot
+settle it. `GROUND_SURFACE_REFERENCE` stays `None`.
-This matters more than the other open questions because getting it wrong does
-not fail. It returns plausible numbers on the wrong datum, and every ingested
-reading is silently wrong. `GROUND_SURFACE_REFERENCE` in `client.py` is
-therefore `None`, and the pipeline refuses to guess.
+Two things resolve it, both automated in `probe_diverhub.py`:
-Resolve it by running `probe_diverhub.py`, which samples all four values for
-one point side by side, and comparing against a well whose depth to water is
-independently known. Record the answer here and set the constant.
+1. **Aligned-row comparison.** An elevation moves opposite to a depth, so
+ `elevation + depth` is constant while `depth − depth` is constant. Comparing
+ rows at the same timestamp separates them; comparing ranges cannot, because
+ an inversion and an offset produce the same spread.
+2. **`ManualMeasurements` as ground truth.** It reports `waterLevelToc` —
+ explicitly top of casing. Whichever reference tracks it *is* the TOC series,
+ and ground surface is the one shallower by the casing stickup. The +68.11
+ gap between references 0 and 1 is a plausible stickup (~0.7 m), which makes
+ that pair the likely GS/TOC candidates — but "likely" is not good enough for
+ a datum, since a wrong choice produces plausible numbers rather than an error.
### Monitoring points — thinner than expected
@@ -140,8 +175,8 @@ Settled, not to be relitigated per source:
| # | Question | How to settle |
|---|---|---|
| 1 | Which `reference` value is ground surface? | `probe_diverhub.py`, compared against a known well |
-| 2 | What is the window ceiling? | `probe_diverhub.py` widens until 500 |
-| 3 | Which project id holds San Acacia, and is it really 33 points? | `probe_diverhub.py` |
+| 2 | ~~What is the window ceiling?~~ | **`WaterLevels` took 730 d / 18111 rows. The 500 is a `DiverData` problem** |
+| 3 | ~~Which project id, how many points?~~ | **Answered: 4317, 38 points (not 33)** |
| 4 | Do `approved=true` and `approved=false` partition the series, or overlap? | Fetch both for one window and compare timestamps |
| 5 | Is `dateAndTime` UTC in the response, and is it marked as such? | Inspect a live payload |
| 6 | Is `level` in feet? | Compare against a known measurement |
From d10b2b457b21aa36d135eef3245fbd3c49d822d0 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 14:22:27 -0700
Subject: [PATCH 070/151] feat(ingestion): resolve the datum enum and the
source unit
Probing settled what the swagger would not say. All four WaterLevelReference
values return the same rows at the same timestamps, related by constants that
held identically across two windows eighteen months apart. A constant sum means
two values move oppositely, a constant difference means they move together, so
ref0 and ref2 rise with the water while ref1 and ref3 fall -- the depths. ref1
is deeper than ref3 by a fixed 45.456 cm, a casing stickup, making ref1 top of
casing and ref3 ground surface.
It checks out physically: ground surface lands at 1394.74 m (4576 ft), right
for San Acacia, and depth to water runs 2.2-4.7 m, right for a riparian
piezometer.
The same arithmetic fixes the unit. ref2 is only an elevation if the readings
are centimetres, since 139002 cm is 1390 m and any other unit puts the ground
somewhere impossible. Ocotillo stores feet, so convert_cm_to_ft joins the
existing conversions in domain/units.py. An unconverted value is wrong by
30.48x and still reads as a plausible depth.
Still uncorroborated: ManualMeasurements returned nothing in the sampled
window, so ref1 being top of casing is inferred from the stickup rather than
matched against a measured one.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/scripts/probe_diverhub.py | 4 +-
.../sources/san_acacia/client.py | 36 ++++++--
.../tests/test_diverhub_client.py | 31 ++++++-
docs/sources/san_acacia.md | 87 +++++++++++--------
domain/units.py | 14 +++
5 files changed, 122 insertions(+), 50 deletions(-)
diff --git a/automated_ingestion/scripts/probe_diverhub.py b/automated_ingestion/scripts/probe_diverhub.py
index c033d4e23..b71cca638 100644
--- a/automated_ingestion/scripts/probe_diverhub.py
+++ b/automated_ingestion/scripts/probe_diverhub.py
@@ -229,7 +229,9 @@ def probe_datum_relationships(
print("\n Manual measurements (waterLevelToc = top of casing):")
try:
- manual = client.manual_measurements(point_id, start, end)
+ # Sparse by nature -- a few per year at best -- so search the whole
+ # record rather than the window used for the logged series.
+ manual = client.manual_measurements(point_id, end - 3650 * DAY, end)
except DiverHubError as exc:
print(f" unavailable -- {exc}")
return
diff --git a/automated_ingestion/sources/san_acacia/client.py b/automated_ingestion/sources/san_acacia/client.py
index 8b70ab1f5..8e3b269e5 100644
--- a/automated_ingestion/sources/san_acacia/client.py
+++ b/automated_ingestion/sources/san_acacia/client.py
@@ -51,18 +51,40 @@
"""Refresh this long before ``validTo``, so a request in flight at the boundary
does not arrive expired."""
-GROUND_SURFACE_REFERENCE: int | None = None
+GROUND_SURFACE_REFERENCE = 3
"""Which ``WaterLevelReference`` value means depth below ground surface.
-The swagger declares the enum as ``[0, 1, 2, 3]`` with no names, so this cannot
-be derived from the specification -- it has to be observed against a well whose
-depth to water is independently known. Deliberately ``None`` until then:
-guessing wrong would not fail, it would silently record every reading on the
-wrong datum, which is the one error this pipeline must not make quietly.
+The swagger declares the enum as ``[0, 1, 2, 3]`` with no names, so this was
+determined by measurement rather than read off the specification. Probing
+SO-0125 showed all four values return the same rows at the same timestamps,
+related by constants that held identically across two windows eighteen months
+apart:
-Set it from the finding of `scripts/probe_diverhub.py`.
+ ref1 + ref0 = 518.160 ref3 + ref0 = 472.704 ref2 - ref0 = 139001.296
+
+``ref0`` and ``ref2`` rise with the water; ``ref1`` and ``ref3`` fall, so the
+latter pair are depths. ``ref1`` is deeper than ``ref3`` by a fixed 45.456 cm
+(1.49 ft) -- a casing stickup -- which makes ``ref1`` top of casing and ``ref3``
+ground surface. The reading checks out physically: ground surface lands at
+1394.74 m (4576 ft), right for San Acacia, and depth to water runs 2.2-4.7 m,
+right for a riparian piezometer.
+
+See ``docs/sources/san_acacia.md``. Do not change this without re-running
+``scripts/probe_diverhub.py``: the wrong value returns plausible numbers on the
+wrong datum rather than an error.
"""
+TOP_OF_CASING_REFERENCE = 1
+"""Depth below top of casing. Not ingested -- recorded so the value is not
+mistaken for ground surface, which it resembles to within a stickup."""
+
+ELEVATION_REFERENCE = 2
+"""Water-surface elevation above sea level. Not ingested."""
+
+SOURCE_UNIT = "cm"
+"""Diver-HUB reports centimeters; Ocotillo stores feet. Convert with
+``domain.units.convert_cm_to_ft`` -- never store a raw value."""
+
class Response(Protocol):
"""The subset of a `requests` response this module uses."""
diff --git a/automated_ingestion/tests/test_diverhub_client.py b/automated_ingestion/tests/test_diverhub_client.py
index 313e2eab7..78ccd37b8 100644
--- a/automated_ingestion/tests/test_diverhub_client.py
+++ b/automated_ingestion/tests/test_diverhub_client.py
@@ -166,12 +166,35 @@ def test_naive_valid_to_is_read_as_utc():
assert naive == aware
-def test_ground_surface_reference_is_unset_until_confirmed():
- # Guessing would not fail loudly; it would record every reading on the
- # wrong datum. The constant stays None until someone observes it.
+def test_datum_constants_match_the_measured_relationships():
+ # Determined by probing, not read from the spec: ref0/ref2 rise with the
+ # water and ref1/ref3 fall, so the depths are 1 and 3, and ref1 is deeper
+ # than ref3 by a fixed casing stickup. Getting this wrong does not raise --
+ # it silently records every reading on the wrong datum.
from automated_ingestion.sources.san_acacia import client as module
- assert module.GROUND_SURFACE_REFERENCE is None
+ assert module.GROUND_SURFACE_REFERENCE == 3
+ assert module.TOP_OF_CASING_REFERENCE == 1
+ assert module.ELEVATION_REFERENCE == 2
+ assert module.GROUND_SURFACE_REFERENCE != module.TOP_OF_CASING_REFERENCE
+
+
+def test_source_unit_is_centimeters_not_feet():
+ # The vendor reports cm and Ocotillo stores ft. A value passed through
+ # unconverted is wrong by a factor of 30.48 and still looks like a plausible
+ # depth, which is exactly the kind of error that survives review.
+ from domain.units import convert_cm_to_ft
+ from automated_ingestion.sources.san_acacia import client as module
+
+ assert module.SOURCE_UNIT == "cm"
+ # SO-0125 on 2024-10-30: 471.518 cm below ground surface.
+ assert convert_cm_to_ft(471.518) == 15.469751
+
+
+def test_cm_conversion_passes_none_through():
+ from domain.units import convert_cm_to_ft
+
+ assert convert_cm_to_ft(None) is None
# ============= EOF =============================================
diff --git a/docs/sources/san_acacia.md b/docs/sources/san_acacia.md
index 79ea05f94..55725aa40 100644
--- a/docs/sources/san_acacia.md
+++ b/docs/sources/san_acacia.md
@@ -92,47 +92,58 @@ otherwise:
`approved` (boolean) selects the vendor's approval state. The same point and
time range returns different numbers depending on what was asked for.
-### WaterLevelReference — measured, not yet decided
+### WaterLevelReference — resolved 2026-08-18
The swagger declares `"WaterLevelReference": { "enum": [0, 1, 2, 3] }` with no
-names and no descriptions, so the meaning cannot be read off the spec.
+names, so this was determined by measurement.
-Sampled for SO-0125 over 365 days — all four return **the same 1054 rows at the
-same timestamps**, differing only by a constant offset. They are one series
-expressed against four datums:
+All four values return **the same rows at the same timestamps**, related by
+constants that held identically across two windows eighteen months apart:
-| `reference` | min | max | offset vs 0 |
+```
+ref1 + ref0 = 518.160 ref3 + ref0 = 472.704 ref2 - ref0 = 139001.296
+```
+
+A constant *sum* means the two move in opposite directions; a constant
+*difference* means they move together. So `ref0` and `ref2` rise with the water
+and `ref1`/`ref3` fall — the latter pair are depths. `ref1` is deeper than
+`ref3` by a fixed **45.456 cm (1.49 ft)**, which is a casing stickup.
+
+| Value | Meaning | Ingested |
+|---|---|---|
+| 0 | Water height above the diver | No |
+| **3** | **Depth below ground surface** | **Yes** |
+| 1 | Depth below top of casing | No |
+| 2 | Water-surface elevation above sea level | No |
+
+`GROUND_SURFACE_REFERENCE = 3`.
+
+Sample values for SO-0125, 2024-10-30T20:00:00Z:
+
+| ref0 | ref1 | ref2 | ref3 |
|---|---|---|---|
-| 0 | 199.356 | 250.697 | — |
-| 1 | 267.462 | 318.804 | +68.11 |
-| 2 | 139200.653 | 139251.994 | +139001.30 |
-| 3 | 222.005 | 273.347 | +22.65 |
-
-Spread is identical to three decimals (51.34) across all four, confirming they
-are the same measurements re-referenced.
-
-**`reference=2` is an elevation, not a depth.** It is three orders of magnitude
-larger than the others. Read as centimetres it is 1392 m ≈ 4567 ft, which
-matches San Acacia's ground elevation — which in turn implies the unit
-throughout is **centimetres**, making 0/1/3 read as roughly 2–3 m depths.
-That is plausible for riparian piezometers and implausible as feet, but it is
-inference from one well, not a confirmed unit.
-
-**Which of 0, 1, 3 is ground surface is still undecided**, and min/max cannot
-settle it. `GROUND_SURFACE_REFERENCE` stays `None`.
-
-Two things resolve it, both automated in `probe_diverhub.py`:
-
-1. **Aligned-row comparison.** An elevation moves opposite to a depth, so
- `elevation + depth` is constant while `depth − depth` is constant. Comparing
- rows at the same timestamp separates them; comparing ranges cannot, because
- an inversion and an offset produce the same spread.
-2. **`ManualMeasurements` as ground truth.** It reports `waterLevelToc` —
- explicitly top of casing. Whichever reference tracks it *is* the TOC series,
- and ground surface is the one shallower by the casing stickup. The +68.11
- gap between references 0 and 1 is a plausible stickup (~0.7 m), which makes
- that pair the likely GS/TOC candidates — but "likely" is not good enough for
- a datum, since a wrong choice produces plausible numbers rather than an error.
+| 1.186 | 516.974 | 139002.482 | 471.518 |
+
+The reading checks out physically. The sensor sits at 1390.01 m; ground surface
+is 4.727 m above it at **1394.74 m (4576 ft)**, right for San Acacia. Depth to
+water runs 4.72 m in October 2024 to 2.2–2.7 m in April 2026, right for a
+riparian piezometer.
+
+**Not independently corroborated.** `ManualMeasurements`, which reports
+`waterLevelToc` explicitly, returned nothing in the sampled window, so `ref1`
+being TOC is inferred from the stickup rather than confirmed against a measured
+one. The probe now searches ten years for a manual reading; a single one would
+close this.
+
+### Units — centimetres, not feet
+
+`ref2` is only an elevation if the unit is centimetres: 139002 cm is 1390 m,
+which matches San Acacia, whereas any other unit puts the ground somewhere
+impossible. That fixes the unit for every value the API returns.
+
+**Ocotillo stores feet.** Convert with `domain.units.convert_cm_to_ft`
+(`/100 * 3.28084`). An unconverted value is wrong by a factor of 30.48 and
+still reads as a plausible depth, so it would survive review.
### Monitoring points — thinner than expected
@@ -174,12 +185,12 @@ Settled, not to be relitigated per source:
| # | Question | How to settle |
|---|---|---|
-| 1 | Which `reference` value is ground surface? | `probe_diverhub.py`, compared against a known well |
+| 1 | ~~Which `reference` value is ground surface?~~ | **Answered: 3.** Corroboration via `ManualMeasurements` still outstanding |
| 2 | ~~What is the window ceiling?~~ | **`WaterLevels` took 730 d / 18111 rows. The 500 is a `DiverData` problem** |
| 3 | ~~Which project id, how many points?~~ | **Answered: 4317, 38 points (not 33)** |
| 4 | Do `approved=true` and `approved=false` partition the series, or overlap? | Fetch both for one window and compare timestamps |
| 5 | Is `dateAndTime` UTC in the response, and is it marked as such? | Inspect a live payload |
-| 6 | Is `level` in feet? | Compare against a known measurement |
+| 6 | ~~Is `level` in feet?~~ | **No — centimetres.** Convert with `convert_cm_to_ft` |
Questions 1 and 6 both gate correctness rather than completeness: wrong answers
produce data that looks fine.
diff --git a/domain/units.py b/domain/units.py
index 66fd5d917..18231e1a8 100644
--- a/domain/units.py
+++ b/domain/units.py
@@ -42,4 +42,18 @@ def convert_m_to_ft(meters: float | None, ndigits: int = 6) -> float | None:
return round(meters * METERS_TO_FEET, ndigits)
+CENTIMETERS_PER_METER = 100.0
+
+
+def convert_cm_to_ft(centimeters: float | None, ndigits: int = 6) -> float | None:
+ """Convert a length from centimeters to feet.
+
+ Diver-HUB reports water levels in centimeters while Ocotillo stores feet,
+ so every ingested reading passes through here.
+ """
+ if centimeters is None:
+ return None
+ return round(centimeters / CENTIMETERS_PER_METER * METERS_TO_FEET, ndigits)
+
+
# ============= EOF =============================================
From aa31edd5a57c89618e30dde4d09b85fb617408a1 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 14:23:41 -0700
Subject: [PATCH 071/151] ci(ingestion): redeploy the code location when db/ or
domain/ change
The code location imports db.engine, db.transducer, and domain.units in
process, so those directories are part of what the image runs -- but neither
was in the path filter. A domain fix merged to production would have left the
pipeline running the old rule against the live database, with nothing to
indicate the deployed code had diverged from the repository.
This commit only triggered a build because it also touched
automated_ingestion/, which is precisely how the gap stays invisible.
The cost is that ordinary API changes under db/ and domain/ now trigger a
Dagster build. A stale code location is the worse failure.
Co-Authored-By: Claude Opus 5
---
.github/workflows/CD_dagster_branch.yml | 8 ++++++++
.github/workflows/CD_dagster_prod.yml | 8 ++++++++
2 files changed, 16 insertions(+)
diff --git a/.github/workflows/CD_dagster_branch.yml b/.github/workflows/CD_dagster_branch.yml
index 4267eb9c0..69394860a 100644
--- a/.github/workflows/CD_dagster_branch.yml
+++ b/.github/workflows/CD_dagster_branch.yml
@@ -10,6 +10,14 @@ on:
types: [opened, synchronize, reopened, closed]
paths:
- "automated_ingestion/**"
+ # The code location imports db/ models and domain/ rules in-process,
+ # so a change to either alters what this image runs even when no
+ # ingestion file moves. Without these, a domain fix merged to
+ # production would leave the pipeline running the old rule against
+ # the live database. The cost is that ordinary API changes to these
+ # directories also trigger a build; a stale code location is worse.
+ - "db/**"
+ - "domain/**"
- "dagster_cloud.yaml"
- "pyproject.toml"
- "uv.lock"
diff --git a/.github/workflows/CD_dagster_prod.yml b/.github/workflows/CD_dagster_prod.yml
index 4cfba270c..1ff7d5de4 100644
--- a/.github/workflows/CD_dagster_prod.yml
+++ b/.github/workflows/CD_dagster_prod.yml
@@ -16,6 +16,14 @@ on:
branches: [production]
paths:
- "automated_ingestion/**"
+ # The code location imports db/ models and domain/ rules in-process,
+ # so a change to either alters what this image runs even when no
+ # ingestion file moves. Without these, a domain fix merged to
+ # production would leave the pipeline running the old rule against
+ # the live database. The cost is that ordinary API changes to these
+ # directories also trigger a build; a stale code location is worse.
+ - "db/**"
+ - "domain/**"
- "dagster_cloud.yaml"
- "pyproject.toml"
- "uv.lock"
From 6c9ab7097b234d37e15fb71e29f42a0cd69422cb Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 14:52:45 -0700
Subject: [PATCH 072/151] feat(ingestion): land San Acacia locations and
readings in the raw zone
Two dlt resources with deliberately different dispositions. Locations replace,
because the roster is a snapshot and a point disappearing is information rather
than something to accumulate. Readings append and carry a dlt cursor, because
the raw zone has to keep what the vendor said at the time for a replay backfill
to mean anything.
Nothing is converted on the way in: the vendor's centimetres and datum are
stored as they arrived, with unit and reference recorded alongside, so a
mapping bug stays a reprocess rather than a re-fetch.
Vendor approval takes two requests, since `approved` is a query parameter and
not a response field. The unfiltered series is authoritative and a second
approved=true fetch only supplies timestamps to tag it -- concatenating both
would duplicate every reading if the sets overlap, which is not yet known. If
that second fetch fails the rows land untagged rather than lost.
Failures are collected per point into a list the caller owns. A dlt resource is
a module-level object shared by every run, so recording per-run state on it
would have one run overwriting another's.
READING_SPAN is 365 days rather than the 90-day default: probing showed this
endpoint serving 730 days in one request, so the default would have quadrupled
the request count for a first run covering a decade.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/defs/assets/__init__.py | 11 +-
.../sources/san_acacia/dlt_pipeline.py | 170 +++++++++++++++++-
.../sources/san_acacia/ingest.py | 107 ++++++++++-
.../tests/test_san_acacia_resources.py | 164 +++++++++++++++++
docs/automated-ingestion-pipeline-plan.md | 25 +--
5 files changed, 460 insertions(+), 17 deletions(-)
create mode 100644 automated_ingestion/tests/test_san_acacia_resources.py
diff --git a/automated_ingestion/defs/assets/__init__.py b/automated_ingestion/defs/assets/__init__.py
index 64621845b..3671abfa5 100644
--- a/automated_ingestion/defs/assets/__init__.py
+++ b/automated_ingestion/defs/assets/__init__.py
@@ -24,11 +24,20 @@
from automated_ingestion.defs.assets.connectivity import database_connectivity
from automated_ingestion.defs.assets.heartbeat import ingestion_heartbeat
+from automated_ingestion.sources.san_acacia.ingest import (
+ raw_san_acacia_locations,
+ raw_san_acacia_readings,
+)
def all_assets() -> list[AssetsDefinition]:
"""Every asset the code location exposes."""
- return [ingestion_heartbeat, database_connectivity]
+ return [
+ ingestion_heartbeat,
+ database_connectivity,
+ raw_san_acacia_locations,
+ raw_san_acacia_readings,
+ ]
# ============= EOF =============================================
diff --git a/automated_ingestion/sources/san_acacia/dlt_pipeline.py b/automated_ingestion/sources/san_acacia/dlt_pipeline.py
index c521b1f55..c7b9e7319 100644
--- a/automated_ingestion/sources/san_acacia/dlt_pipeline.py
+++ b/automated_ingestion/sources/san_acacia/dlt_pipeline.py
@@ -14,11 +14,173 @@
# limitations under the License.
# ===============================================================================
"""
-dlt resources for the Van Essen API.
+dlt resources landing San Acacia in the GCS raw zone.
-Two resources: ``locations`` (33 wells, ``replace``, one call) and ``readings``
-(per-point, ``append``, incremental on the reading timestamp). Built under BDMS
-tasks 2.2 and 2.3; 2.3 waits on the vendor-side endpoint failure.
+Two resources, with deliberately different dispositions:
+
+* ``vanessen_locations`` -- the monitoring point roster, ``replace``. It is a
+ snapshot of what the vendor currently lists, and a point disappearing is
+ information we want to see rather than accumulate.
+* ``vanessen_readings`` -- the water level series, ``append``, incremental on
+ the reading timestamp. Appending is what makes Mode B replay possible: the
+ raw zone keeps what the vendor said at the time, not just what it says now.
+
+Nothing is transformed here. The raw zone stores the vendor's payload as it
+arrived, in the vendor's units and on the vendor's datum, so a mapping bug is a
+reprocess rather than a re-fetch. Conversion to Ocotillo's model happens in the
+adapter, downstream.
"""
+from collections.abc import Iterator
+from typing import Any
+
+import dlt
+
+from automated_ingestion.shared.gcs import RAW_LAYOUT, raw_zone_bucket
+from automated_ingestion.shared.windows import DAY
+from automated_ingestion.shared.source_registry import SourceDefinition, register
+from automated_ingestion.sources.san_acacia.client import (
+ GROUND_SURFACE_REFERENCE,
+ SOURCE_UNIT,
+ DiverHubClient,
+ DiverHubError,
+)
+
+PROJECT_ID = 4317
+"""Diver-HUB project ``SanAcaciaReach``. Confirmed by probing, not assumed."""
+
+READING_SPAN = 365 * DAY
+"""Window width for this source, measured rather than assumed.
+
+``WaterLevels`` served 730 days and 18111 rows in a single request when probed,
+so the generic 90-day default in ``shared/windows.py`` would quadruple the
+request count for no benefit -- a first run for one point covers a decade. This
+sits at half the largest span observed to work, leaving room for a denser point
+than SO-0125.
+"""
+
+INITIAL_START = "2015-01-01T00:00:00+00:00"
+"""Floor for a point that has never been ingested.
+
+A floor, never a backfill lever: moving it forward does not delete anything
+already landed, and moving it backward does not fetch history for a point whose
+cursor has advanced past it. Use a backfill job for that
+(``BACKFILL_STRATEGY.md`` section 2).
+"""
+
+SOURCE = register(
+ SourceDefinition(
+ key="san_acacia",
+ display_name="San Acacia Reach",
+ dataset_name="raw_sanacaciareach",
+ )
+)
+
+
+@dlt.resource(name="vanessen_locations", write_disposition="replace")
+def vanessen_locations(client: DiverHubClient) -> Iterator[dict[str, Any]]:
+ """The monitoring point roster.
+
+ One request, no pagination. The payload is ``{id, name}`` and nothing more
+ -- no coordinates, no construction detail -- so this cannot be the source
+ of a well's geometry. It exists to enumerate the points a reading fetch
+ walks, and to record what the vendor listed on a given day.
+ """
+ for point in client.monitoring_points(PROJECT_ID):
+ yield {
+ "monitoring_point_id": point["id"],
+ "name": point["name"],
+ "project_id": PROJECT_ID,
+ }
+
+
+@dlt.resource(name="vanessen_readings", write_disposition="append")
+def vanessen_readings(
+ client: DiverHubClient,
+ monitoring_points: list[dict[str, Any]],
+ end: int,
+ failures: list[dict[str, Any]],
+ cursor: dlt.sources.incremental[str] = dlt.sources.incremental(
+ "dateAndTime", initial_value=INITIAL_START
+ ),
+) -> Iterator[dict[str, Any]]:
+ """Water levels for every point, from each point's watermark to ``end``.
+
+ Failure is isolated per point. One diver returning a 500 for its whole
+ history should cost that diver's data for this run, not the other
+ thirty-seven -- so exceptions are caught here and appended to ``failures``
+ rather than raised.
+
+ ``failures`` is supplied by the caller rather than stashed on the resource:
+ a dlt resource is a module-level object shared by every run, so recording
+ per-run state on it would have one run overwriting another's.
+ """
+ from automated_ingestion.sources.san_acacia.client import _parse_timestamp
+
+ start = int(_parse_timestamp(cursor.last_value))
+
+ for point in monitoring_points:
+ point_id = point["monitoring_point_id"]
+ try:
+ approved_at = _approved_timestamps(client, point_id, start, end)
+ for row in client.water_levels(
+ point_id,
+ start,
+ end,
+ reference=GROUND_SURFACE_REFERENCE,
+ span=READING_SPAN,
+ ):
+ yield {
+ "monitoring_point_id": point_id,
+ "name": point["name"],
+ "dateAndTime": row["dateAndTime"],
+ "level": row["level"],
+ "unit": SOURCE_UNIT,
+ "reference": GROUND_SURFACE_REFERENCE,
+ "vendor_approved": row["dateAndTime"] in approved_at,
+ }
+ except DiverHubError as exc:
+ failures.append({"monitoring_point_id": point_id, "error": str(exc)})
+
+
+def _approved_timestamps(
+ client: DiverHubClient, point_id: int, start: int, end: int
+) -> set[str]:
+ """Timestamps the vendor has marked approved.
+
+ ``approved`` is a request parameter rather than a response field, so the
+ flag has to be recovered by asking twice. We take the unfiltered series as
+ the authoritative row set and use this only to tag it -- fetching
+ ``approved=true`` and ``approved=false`` separately and concatenating would
+ duplicate every row if the two sets overlap, which is not yet known.
+
+ A failure here is not fatal: an untagged reading is worth more than no
+ reading, and the vendor flag is not Ocotillo's review status anyway.
+ """
+ try:
+ rows = client.water_levels(
+ point_id,
+ start,
+ end,
+ reference=GROUND_SURFACE_REFERENCE,
+ approved=True,
+ span=READING_SPAN,
+ )
+ return {row["dateAndTime"] for row in rows}
+ except DiverHubError:
+ return set()
+
+
+def build_pipeline(environment: str) -> Any:
+ """A dlt pipeline writing parquet to the raw zone for one environment."""
+ return dlt.pipeline(
+ pipeline_name=f"san_acacia_{environment}",
+ destination=dlt.destinations.filesystem(
+ bucket_url=f"gs://{raw_zone_bucket()}",
+ layout=RAW_LAYOUT,
+ ),
+ dataset_name=SOURCE.dataset_name,
+ )
+
+
# ============= EOF =============================================
diff --git a/automated_ingestion/sources/san_acacia/ingest.py b/automated_ingestion/sources/san_acacia/ingest.py
index 32160edcf..6486f2a2d 100644
--- a/automated_ingestion/sources/san_acacia/ingest.py
+++ b/automated_ingestion/sources/san_acacia/ingest.py
@@ -13,6 +13,111 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
-"""Dagster assets for the San Acacia source. Declared under BDMS task 2."""
+"""
+Dagster assets for San Acacia.
+
+Both assets land raw payloads in GCS and report what happened as metadata --
+row counts, and for readings the number of points that failed. A run that
+silently ingests nothing looks identical to a run with nothing to ingest, and
+the metadata is what separates them.
+"""
+
+from datetime import datetime, timezone
+from typing import Any
+
+from dagster import AssetExecutionContext, MetadataValue, Output, asset
+
+from automated_ingestion.sources.san_acacia.client import DiverHubClient
+
+
+def _client() -> DiverHubClient:
+ import requests
+
+ return DiverHubClient(requests.Session())
+
+
+@asset(
+ group_name="san_acacia",
+ description="Monitoring point roster for the San Acacia project, landed raw.",
+)
+def raw_san_acacia_locations(context: AssetExecutionContext) -> Output[int]:
+ """Land the point roster in the raw zone."""
+ from automated_ingestion.sources.san_acacia.dlt_pipeline import (
+ PROJECT_ID,
+ build_pipeline,
+ vanessen_locations,
+ )
+
+ client = _client()
+ points = list(client.monitoring_points(PROJECT_ID))
+ pipeline = build_pipeline(context.run.tags.get("environment", "staging"))
+ pipeline.run(vanessen_locations(client))
+
+ context.log.info("landed %s monitoring points", len(points))
+ return Output(
+ len(points),
+ metadata={
+ "monitoring_points": MetadataValue.int(len(points)),
+ "project_id": MetadataValue.int(PROJECT_ID),
+ "names": MetadataValue.text(", ".join(p["name"] for p in points[:10])),
+ },
+ )
+
+
+@asset(
+ group_name="san_acacia",
+ deps=[raw_san_acacia_locations],
+ description="Water level readings for every San Acacia point, landed raw.",
+)
+def raw_san_acacia_readings(context: AssetExecutionContext) -> Output[int]:
+ """Land water levels for every point, isolating per-point failure."""
+ from automated_ingestion.sources.san_acacia.dlt_pipeline import (
+ PROJECT_ID,
+ build_pipeline,
+ vanessen_readings,
+ )
+
+ client = _client()
+ points = [
+ {"monitoring_point_id": p["id"], "name": p["name"]}
+ for p in client.monitoring_points(PROJECT_ID)
+ ]
+ end = int(datetime.now(tz=timezone.utc).timestamp())
+
+ pipeline = build_pipeline(context.run.tags.get("environment", "staging"))
+ failures: list[dict[str, Any]] = []
+ info = pipeline.run(vanessen_readings(client, points, end, failures))
+ rows = _row_count(info)
+
+ if failures:
+ context.log.warning(
+ "%s of %s points failed: %s",
+ len(failures),
+ len(points),
+ ", ".join(str(f["monitoring_point_id"]) for f in failures),
+ )
+
+ return Output(
+ rows,
+ metadata={
+ "rows_ingested": MetadataValue.int(rows),
+ "points_attempted": MetadataValue.int(len(points)),
+ "points_failed": MetadataValue.int(len(failures)),
+ "failures": MetadataValue.json(failures),
+ },
+ )
+
+
+def _row_count(load_info: Any) -> int:
+ """Rows dlt reports as loaded, or 0 when it reports nothing."""
+ try:
+ return sum(
+ metrics.get("rows_count", 0)
+ for job in load_info.load_packages
+ for metrics in getattr(job, "jobs", {}).values()
+ )
+ except Exception: # noqa: BLE001 - metadata must never fail a good load
+ return 0
+
# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_san_acacia_resources.py b/automated_ingestion/tests/test_san_acacia_resources.py
new file mode 100644
index 000000000..64fe21fbb
--- /dev/null
+++ b/automated_ingestion/tests/test_san_acacia_resources.py
@@ -0,0 +1,164 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Resource behaviour: failure isolation, approval tagging, and the raw-zone
+contract that nothing is converted on the way in.
+"""
+
+from automated_ingestion.sources.san_acacia.client import (
+ GROUND_SURFACE_REFERENCE,
+ DiverHubClient,
+)
+from automated_ingestion.sources.san_acacia.dlt_pipeline import (
+ PROJECT_ID,
+ vanessen_locations,
+ vanessen_readings,
+)
+from automated_ingestion.tests.test_diverhub_client import FakeResponse, FakeTransport
+
+
+class ScriptedTransport(FakeTransport):
+ """Answers per-path so a single point can be made to fail."""
+
+ def __init__(self, handler):
+ super().__init__()
+ self._handler = handler
+
+ def get(self, url, **kwargs):
+ self.gets.append((url, kwargs))
+ return self._handler(url, kwargs)
+
+
+def _points_payload():
+ return [{"id": 39, "name": "SO-0125"}, {"id": 40, "name": "SO-0131"}]
+
+
+def test_locations_flatten_to_the_raw_shape():
+ transport = ScriptedTransport(lambda url, kw: FakeResponse(200, _points_payload()))
+ client = DiverHubClient(transport, username="u", password="p")
+ rows = list(vanessen_locations(client))
+ assert rows == [
+ {"monitoring_point_id": 39, "name": "SO-0125", "project_id": PROJECT_ID},
+ {"monitoring_point_id": 40, "name": "SO-0131", "project_id": PROJECT_ID},
+ ]
+
+
+READINGS = [
+ {"dateAndTime": "2026-04-15T22:45:00", "level": 199.356},
+ {"dateAndTime": "2026-04-15T23:00:00", "level": 200.0},
+]
+
+
+def _within(rows, params):
+ """Return only rows inside the requested window, as the API does.
+
+ A stub that ignores startTime/endTime returns its whole payload for every
+ window, which turns a decade-long fetch into fifty copies of the same rows
+ and hides whether the caller is windowing correctly at all.
+ """
+ from automated_ingestion.sources.san_acacia.client import _parse_timestamp
+
+ start, end = params["startTime"], params["endTime"]
+ return [r for r in rows if start <= _parse_timestamp(r["dateAndTime"]) <= end]
+
+
+def _reading_handler(failing_point=None, approved_stamps=()):
+ def handler(url, kwargs):
+ if "WaterLevels" in url:
+ point_id = int(url.rstrip("/").split("/")[-1])
+ if point_id == failing_point:
+ return FakeResponse(500)
+ params = kwargs.get("params", {})
+ if params.get("approved"):
+ approved = [{"dateAndTime": s, "level": 1.0} for s in approved_stamps]
+ return FakeResponse(200, _within(approved, params))
+ return FakeResponse(200, _within(READINGS, params))
+ return FakeResponse(200, [])
+
+ return handler
+
+
+def _run_readings(handler, points=None, failures=None):
+ transport = ScriptedTransport(handler)
+ client = DiverHubClient(transport, username="u", password="p")
+ points = (
+ points
+ if points is not None
+ else [
+ {"monitoring_point_id": 39, "name": "SO-0125"},
+ {"monitoring_point_id": 40, "name": "SO-0131"},
+ ]
+ )
+ collected = failures if failures is not None else []
+ resource = vanessen_readings(client, points, 1_800_000_000, collected)
+ return list(resource), collected
+
+
+def test_readings_carry_unit_and_reference_untransformed():
+ # The raw zone stores what the vendor said, on the vendor's datum in the
+ # vendor's units. Converting here would make a mapping bug a re-fetch
+ # instead of a reprocess.
+ rows, _ = _run_readings(_reading_handler())
+ assert rows[0]["level"] == 199.356
+ assert rows[0]["unit"] == "cm"
+ assert rows[0]["reference"] == GROUND_SURFACE_REFERENCE
+
+
+def test_one_failing_point_does_not_lose_the_others():
+ rows, failures = _run_readings(_reading_handler(failing_point=39))
+ assert [r["monitoring_point_id"] for r in rows] == [40, 40]
+ assert len(failures) == 1
+ assert failures[0]["monitoring_point_id"] == 39
+
+
+def test_failures_are_recorded_for_the_caller_not_the_resource():
+ # Per-run state on a module-level resource would have concurrent runs
+ # overwriting one another.
+ own = []
+ _run_readings(_reading_handler(failing_point=39), failures=own)
+ assert len(own) == 1
+ assert not hasattr(vanessen_readings, "failures")
+
+
+def test_vendor_approval_tags_rows_without_duplicating_them():
+ rows, _ = _run_readings(
+ _reading_handler(approved_stamps=["2026-04-15T22:45:00"]),
+ points=[{"monitoring_point_id": 39, "name": "SO-0125"}],
+ )
+ # Two readings in, two readings out -- the approved fetch tags, never adds.
+ assert len(rows) == 2
+ assert rows[0]["vendor_approved"] is True
+ assert rows[1]["vendor_approved"] is False
+
+
+def test_unavailable_approval_flag_does_not_lose_readings():
+ def handler(url, kwargs):
+ if "WaterLevels" in url:
+ params = kwargs.get("params", {})
+ if params.get("approved"):
+ return FakeResponse(500)
+ return FakeResponse(200, _within(READINGS[:1], params))
+ return FakeResponse(200, [])
+
+ rows, failures = _run_readings(
+ handler, points=[{"monitoring_point_id": 39, "name": "SO-0125"}]
+ )
+ assert len(rows) == 1
+ assert rows[0]["vendor_approved"] is False
+ assert failures == []
+
+
+# ============= EOF =============================================
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index 0cd7f8704..7a3773081 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -215,20 +215,23 @@ Also still open: the window ceiling (three months works, the limit is unmeasured
### 2.2 — dlt resource: locations → GCS
-- `@dlt.resource(name="vanessen_locations")` on `GET /api/api/locations/sanacaciareach`; no pagination, all 33 in one response. `write_disposition="replace"`.
-- HTTP layer: timeouts, bounded retries with backoff, clear failure message. Doubled `/api/api/` segment preserved — confirmed, not a typo.
-- Asset `raw_san_acacia_locations` emits row-count metadata. Tested against fixture, no network.
+- ✅ `@dlt.resource(name="vanessen_locations")`, `write_disposition="replace"`, on `MonitoringPoints/ByProject/4317` — **not** the `locations/sanacaciareach` path in the original draft, which does not exist. One request, no pagination.
+- ✅ Asset `raw_san_acacia_locations` emits the point count, project id, and a sample of names. Tested against a stub, no network.
+- ✅ `replace` rather than `append`: this is a snapshot of what the vendor currently lists, and a point disappearing is information rather than something to accumulate.
+
+The payload is `{id, name}` only, so this cannot be a source of geometry or construction detail — it enumerates the points a reading fetch walks. **38 points, not the 33 the plan assumes**, still unexplained.
### 2.3 — dlt resource: readings → GCS, incremental
-- `@dlt.resource(name="vanessen_readings")` per monitoring point, dlt incremental cursor on reading timestamp, `write_disposition="append"`.
-- **Windowed requests.** `DiverData/ByMonitoringPoint/{id}` takes `startTime`/`endTime` as Unix seconds and 500s on an oversized span, so a fetch is always a sequence of bounded windows — never one open-ended call. This is true of the daily incremental run too, not just backfill: an entity whose cursor has fallen months behind must walk forward in chunks.
-- **Token refresh mid-run.** The JWT expires after an hour. Refresh on expiry and retry once on a 401; a multi-hour backfill must not die at minute 61.
-- Treat a 500 on a windowed request as a signal to halve the window and retry, not as a dead entity — the endpoint reports "too much data" that way.
-- `initial_start_date` in `.dlt/config.toml`, documented as a floor for entities with no cursor yet — never a backfill lever (`BACKFILL_STRATEGY.md` §2).
-- Vendor approved/unapproved flag preserved per row.
-- Per-entity failure doesn't abort the run; failures counted and surfaced as asset metadata.
-- Asset `raw_san_acacia_readings` emits rows-ingested and entities-failed. Tested against fixtures.
+- ✅ `@dlt.resource(name="vanessen_readings")`, `write_disposition="append"`, dlt incremental cursor on `dateAndTime`, walking each point from its watermark.
+- ✅ `INITIAL_START` (2015-01-01) documented as a floor for a point with no cursor, never a backfill lever.
+- ✅ Per-point failure isolation: one diver failing costs that diver's data for the run, not the other thirty-seven. Failures are collected into a list **the caller owns** — a dlt resource is a module-level object shared by every run, so per-run state stashed on it would have concurrent runs overwriting each other.
+- ✅ Asset `raw_san_acacia_readings` emits rows ingested, points attempted, points failed, and the failures themselves.
+- ✅ Nothing is converted on the way in. The raw zone stores the vendor's `level` in the vendor's centimetres on the vendor's datum, with `unit` and `reference` recorded alongside, so a mapping bug is a reprocess rather than a re-fetch.
+
+**Vendor approval needs two requests.** `approved` is a query parameter, not a response field, so the flag cannot be read off a row. Fetching `approved=true` and `approved=false` separately and concatenating would duplicate every reading if the two sets overlap — which is still unknown (open question 4). Instead the unfiltered series is authoritative and a second `approved=true` fetch supplies a set of timestamps used only to tag it. A failure of that second fetch leaves rows tagged `false` rather than losing them: an untagged reading is worth more than no reading, and the vendor flag is not Ocotillo's review status regardless.
+
+**Window span is measured, not inherited.** `READING_SPAN` is 365 days for this source rather than the cautious 90-day default in `shared/windows.py`, because probing showed `WaterLevels` serving 730 days and 18111 rows in one request. At 90 days a first run for a single point would issue four times the requests for no benefit. It sits at half the largest span observed to work, leaving headroom for a denser point than SO-0125.
---
From a8a7400a844d2023ce1338e15794db922b2094ec Mon Sep 17 00:00:00 2001
From: Jake Ross
Date: Tue, 18 Aug 2026 15:10:28 -0700
Subject: [PATCH 073/151] Add Dagster Cloud deploy actions
---
.github/workflows/branch_deployments.yml | 77 +++++++++++++++++++++++
.github/workflows/deploy.yml | 79 ++++++++++++++++++++++++
2 files changed, 156 insertions(+)
create mode 100644 .github/workflows/branch_deployments.yml
create mode 100644 .github/workflows/deploy.yml
diff --git a/.github/workflows/branch_deployments.yml b/.github/workflows/branch_deployments.yml
new file mode 100644
index 000000000..bfe4ff80e
--- /dev/null
+++ b/.github/workflows/branch_deployments.yml
@@ -0,0 +1,77 @@
+name: Serverless Branch Deployments
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, closed]
+
+concurrency:
+ # Cancel in-progress deploys to same branch
+ group: ${{ github.ref }}/branch_deployments
+ cancel-in-progress: true
+env:
+ DAGSTER_CLOUD_URL: "http://nmbgmr-data-services.dagster.plus"
+ DAGSTER_CLOUD_API_TOKEN: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+ ENABLE_FAST_DEPLOYS: 'true'
+ PYTHON_VERSION: '3.10'
+ DAGSTER_CLOUD_FILE: 'dagster_cloud.yaml'
+
+jobs:
+ dagster_cloud_default_deploy:
+ name: Dagster Serverless Deploy
+ runs-on: ubuntu-22.04
+ outputs:
+ build_info: ${{ steps.parse-workspace.outputs.build_info }}
+
+ steps:
+ - name: Prerun Checks
+ id: prerun
+ uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v1.13.18
+
+ - name: Launch Docker Deploy
+ if: steps.prerun.outputs.result == 'docker-deploy'
+ id: parse-workspace
+ uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v1.13.18
+ with:
+ dagster_cloud_file: $DAGSTER_CLOUD_FILE
+
+ - name: Checkout for Python Executable Deploy
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ ref: ${{ github.head_ref }}
+ path: project-repo
+
+ - name: Python Executable Deploy
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ uses: dagster-io/dagster-cloud-action/actions/build_deploy_python_executable@v1.13.18
+ with:
+ dagster_cloud_file: "$GITHUB_WORKSPACE/project-repo/$DAGSTER_CLOUD_FILE"
+ build_output_dir: "$GITHUB_WORKSPACE/build"
+ python_version: "${{ env.PYTHON_VERSION }}"
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ dagster_cloud_docker_deploy:
+ name: Docker Deploy
+ runs-on: ubuntu-22.04
+ if: needs.dagster_cloud_default_deploy.outputs.build_info
+ needs: dagster_cloud_default_deploy
+ strategy:
+ fail-fast: false
+ matrix:
+ location: ${{ fromJSON(needs.dagster_cloud_default_deploy.outputs.build_info) }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ ref: ${{ github.head_ref }}
+ - name: Build and deploy to Dagster Cloud serverless
+ uses: dagster-io/dagster-cloud-action/actions/serverless_branch_deploy@v1.13.18
+ with:
+ dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+ location: ${{ toJson(matrix.location) }}
+ base_image: "python:${{ env.PYTHON_VERSION }}-slim"
+ # Uncomment to pass through Github Action secrets as a JSON string of key-value pairs
+ # env_vars: ${{ toJson(secrets) }}
+ organization_id: ${{ secrets.ORGANIZATION_ID }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 000000000..154748d05
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,79 @@
+name: Serverless Prod Deployment
+on:
+ push:
+ branches:
+ - "main"
+ - "master"
+
+concurrency:
+ # Cancel in-progress deploys to same branch
+ group: ${{ github.ref }}/deploy
+ cancel-in-progress: true
+env:
+ DAGSTER_CLOUD_URL: "http://nmbgmr-data-services.dagster.plus"
+ DAGSTER_CLOUD_API_TOKEN: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+ ENABLE_FAST_DEPLOYS: 'true'
+ PYTHON_VERSION: '3.10'
+ DAGSTER_CLOUD_FILE: 'dagster_cloud.yaml'
+
+jobs:
+ dagster_cloud_default_deploy:
+ name: Dagster Serverless Deploy
+ runs-on: ubuntu-22.04
+ outputs:
+ build_info: ${{ steps.parse-workspace.outputs.build_info }}
+
+ steps:
+ - name: Prerun Checks
+ id: prerun
+ uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v1.13.18
+
+ - name: Launch Docker Deploy
+ if: steps.prerun.outputs.result == 'docker-deploy'
+ id: parse-workspace
+ uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v1.13.18
+ with:
+ dagster_cloud_file: $DAGSTER_CLOUD_FILE
+
+ - name: Checkout for Python Executable Deploy
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ ref: ${{ github.head_ref }}
+ path: project-repo
+
+ - name: Python Executable Deploy
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ uses: dagster-io/dagster-cloud-action/actions/build_deploy_python_executable@v1.13.18
+ with:
+ dagster_cloud_file: "$GITHUB_WORKSPACE/project-repo/$DAGSTER_CLOUD_FILE"
+ build_output_dir: "$GITHUB_WORKSPACE/build"
+ python_version: "${{ env.PYTHON_VERSION }}"
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ dagster_cloud_docker_deploy:
+ name: Docker Deploy
+ runs-on: ubuntu-22.04
+ if: needs.dagster_cloud_default_deploy.outputs.build_info
+ needs: dagster_cloud_default_deploy
+ strategy:
+ fail-fast: false
+ matrix:
+ location: ${{ fromJSON(needs.dagster_cloud_default_deploy.outputs.build_info) }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ ref: ${{ github.head_ref }}
+ - name: Build and deploy to Dagster Cloud serverless
+ uses: dagster-io/dagster-cloud-action/actions/serverless_prod_deploy@v1.13.18
+ with:
+ dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+ location: ${{ toJson(matrix.location) }}
+ base_image: "python:${{ env.PYTHON_VERSION }}-slim"
+ # Uncomment to pass through Github Action secrets as a JSON string of key-value pairs
+ # env_vars: ${{ toJson(secrets) }}
+ organization_id: ${{ secrets.ORGANIZATION_ID }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
From 8f49736df397a3cc6a2f6205185c335e3603c60d Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 15:16:09 -0700
Subject: [PATCH 074/151] chore(ci): remove the duplicated Dagster+ deploy
workflows
The Dagster+ GitHub import wizard added branch_deployments.yml and deploy.yml,
which target the same ocotillo-automated-ingestion code location as the
CD_dagster_* workflows already in the repository. Every qualifying pull request
was building and deploying that location twice.
Keeping the CD_dagster_* pair, for three reasons. They pin PYTHON_VERSION to
3.13; the wizard's pin 3.10, which cannot install a lockfile resolved for
requires-python >= 3.13 and fails the way the first deploy here did. They are
path-filtered to the directories that actually change the image, including db/
and domain/, where the wizard's run on every pull request regardless. And they
generate requirements.txt from the `ingestion` dependency group, which is where
dagster and dlt live in this project.
The wizard's workflows do enable PEX fast deploys, which are quicker than the
Docker path we use. That is worth revisiting once the dependency set is known
to work under PEX, but not at the cost of a deploy that cannot install.
Co-Authored-By: Claude Opus 5
---
.github/workflows/branch_deployments.yml | 77 -----------------------
.github/workflows/deploy.yml | 79 ------------------------
2 files changed, 156 deletions(-)
delete mode 100644 .github/workflows/branch_deployments.yml
delete mode 100644 .github/workflows/deploy.yml
diff --git a/.github/workflows/branch_deployments.yml b/.github/workflows/branch_deployments.yml
deleted file mode 100644
index bfe4ff80e..000000000
--- a/.github/workflows/branch_deployments.yml
+++ /dev/null
@@ -1,77 +0,0 @@
-name: Serverless Branch Deployments
-on:
- pull_request:
- types: [opened, synchronize, reopened, closed]
-
-concurrency:
- # Cancel in-progress deploys to same branch
- group: ${{ github.ref }}/branch_deployments
- cancel-in-progress: true
-env:
- DAGSTER_CLOUD_URL: "http://nmbgmr-data-services.dagster.plus"
- DAGSTER_CLOUD_API_TOKEN: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
- ENABLE_FAST_DEPLOYS: 'true'
- PYTHON_VERSION: '3.10'
- DAGSTER_CLOUD_FILE: 'dagster_cloud.yaml'
-
-jobs:
- dagster_cloud_default_deploy:
- name: Dagster Serverless Deploy
- runs-on: ubuntu-22.04
- outputs:
- build_info: ${{ steps.parse-workspace.outputs.build_info }}
-
- steps:
- - name: Prerun Checks
- id: prerun
- uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v1.13.18
-
- - name: Launch Docker Deploy
- if: steps.prerun.outputs.result == 'docker-deploy'
- id: parse-workspace
- uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v1.13.18
- with:
- dagster_cloud_file: $DAGSTER_CLOUD_FILE
-
- - name: Checkout for Python Executable Deploy
- if: steps.prerun.outputs.result == 'pex-deploy'
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- with:
- ref: ${{ github.head_ref }}
- path: project-repo
-
- - name: Python Executable Deploy
- if: steps.prerun.outputs.result == 'pex-deploy'
- uses: dagster-io/dagster-cloud-action/actions/build_deploy_python_executable@v1.13.18
- with:
- dagster_cloud_file: "$GITHUB_WORKSPACE/project-repo/$DAGSTER_CLOUD_FILE"
- build_output_dir: "$GITHUB_WORKSPACE/build"
- python_version: "${{ env.PYTHON_VERSION }}"
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- dagster_cloud_docker_deploy:
- name: Docker Deploy
- runs-on: ubuntu-22.04
- if: needs.dagster_cloud_default_deploy.outputs.build_info
- needs: dagster_cloud_default_deploy
- strategy:
- fail-fast: false
- matrix:
- location: ${{ fromJSON(needs.dagster_cloud_default_deploy.outputs.build_info) }}
- steps:
- - name: Checkout
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- with:
- ref: ${{ github.head_ref }}
- - name: Build and deploy to Dagster Cloud serverless
- uses: dagster-io/dagster-cloud-action/actions/serverless_branch_deploy@v1.13.18
- with:
- dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
- location: ${{ toJson(matrix.location) }}
- base_image: "python:${{ env.PYTHON_VERSION }}-slim"
- # Uncomment to pass through Github Action secrets as a JSON string of key-value pairs
- # env_vars: ${{ toJson(secrets) }}
- organization_id: ${{ secrets.ORGANIZATION_ID }}
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
deleted file mode 100644
index 154748d05..000000000
--- a/.github/workflows/deploy.yml
+++ /dev/null
@@ -1,79 +0,0 @@
-name: Serverless Prod Deployment
-on:
- push:
- branches:
- - "main"
- - "master"
-
-concurrency:
- # Cancel in-progress deploys to same branch
- group: ${{ github.ref }}/deploy
- cancel-in-progress: true
-env:
- DAGSTER_CLOUD_URL: "http://nmbgmr-data-services.dagster.plus"
- DAGSTER_CLOUD_API_TOKEN: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
- ENABLE_FAST_DEPLOYS: 'true'
- PYTHON_VERSION: '3.10'
- DAGSTER_CLOUD_FILE: 'dagster_cloud.yaml'
-
-jobs:
- dagster_cloud_default_deploy:
- name: Dagster Serverless Deploy
- runs-on: ubuntu-22.04
- outputs:
- build_info: ${{ steps.parse-workspace.outputs.build_info }}
-
- steps:
- - name: Prerun Checks
- id: prerun
- uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v1.13.18
-
- - name: Launch Docker Deploy
- if: steps.prerun.outputs.result == 'docker-deploy'
- id: parse-workspace
- uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v1.13.18
- with:
- dagster_cloud_file: $DAGSTER_CLOUD_FILE
-
- - name: Checkout for Python Executable Deploy
- if: steps.prerun.outputs.result == 'pex-deploy'
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- with:
- ref: ${{ github.head_ref }}
- path: project-repo
-
- - name: Python Executable Deploy
- if: steps.prerun.outputs.result == 'pex-deploy'
- uses: dagster-io/dagster-cloud-action/actions/build_deploy_python_executable@v1.13.18
- with:
- dagster_cloud_file: "$GITHUB_WORKSPACE/project-repo/$DAGSTER_CLOUD_FILE"
- build_output_dir: "$GITHUB_WORKSPACE/build"
- python_version: "${{ env.PYTHON_VERSION }}"
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- dagster_cloud_docker_deploy:
- name: Docker Deploy
- runs-on: ubuntu-22.04
- if: needs.dagster_cloud_default_deploy.outputs.build_info
- needs: dagster_cloud_default_deploy
- strategy:
- fail-fast: false
- matrix:
- location: ${{ fromJSON(needs.dagster_cloud_default_deploy.outputs.build_info) }}
- steps:
- - name: Checkout
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- with:
- ref: ${{ github.head_ref }}
- - name: Build and deploy to Dagster Cloud serverless
- uses: dagster-io/dagster-cloud-action/actions/serverless_prod_deploy@v1.13.18
- with:
- dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
- location: ${{ toJson(matrix.location) }}
- base_image: "python:${{ env.PYTHON_VERSION }}-slim"
- # Uncomment to pass through Github Action secrets as a JSON string of key-value pairs
- # env_vars: ${{ toJson(secrets) }}
- organization_id: ${{ secrets.ORGANIZATION_ID }}
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
From a4b16b2b0fdbc4b4e1199383a9307e76ef656023 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 15:27:11 -0700
Subject: [PATCH 075/151] chore(ci): track staging for the Dagster+ prod code
location
The workflow triggered on `production`, which is still on 1.2.2 and has no
automated_ingestion/ directory, so it would never have fired. Every deploy so
far has been a branch deployment from a pull request, leaving the prod code
location empty -- which is why Dagster+ sits on "waiting for GitHub Actions
build to start".
"prod" names the Dagster+ deployment, not this repository's production branch.
Tracking staging matches how ocotillo-api-staging already behaves and keeps the
location current with where the work is.
The original reasoning -- that a pipeline writing to the production database
should follow the release flow -- still holds, and the comment now says when to
act on it: move the trigger back to `production` in the same change that first
points the pipeline at the production database. Until then a deploy publishes
code and not data, since the location has neither database nor vendor
credentials.
Co-Authored-By: Claude Opus 5
---
.github/workflows/CD_dagster_prod.yml | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/CD_dagster_prod.yml b/.github/workflows/CD_dagster_prod.yml
index 1ff7d5de4..edab044d8 100644
--- a/.github/workflows/CD_dagster_prod.yml
+++ b/.github/workflows/CD_dagster_prod.yml
@@ -1,9 +1,16 @@
# Deploys the `ocotillo-automated-ingestion` code location to the Dagster+ prod
# deployment.
#
-# Triggered on `production`, not `main`: `main` was abandoned in July 2025 and
-# the release flow runs feature -> staging -> production (docs/release-flow.md).
-# The plan document's reference to `main` predates that being checked.
+# "prod" here names the Dagster+ deployment, not the API's `production` branch.
+# It tracks `staging`, which is this repository's integration branch and the
+# only place `automated_ingestion/` currently exists -- `production` is still on
+# 1.2.2 and would never fire. This mirrors how `ocotillo-api-staging` follows
+# staging, and keeps the code location current with the work.
+#
+# Move this to `production` in the same change that first points the pipeline at
+# the production database. Until then a deploy here publishes code, not data:
+# the location has no database or vendor credentials, so the worst it can do is
+# fail to materialize.
#
# Path-filtered so an ordinary API change does not spend a Dagster+ build. The
# filter includes pyproject.toml and uv.lock because the location's dependency
@@ -13,7 +20,7 @@ name: CD (Dagster+ prod)
on:
push:
- branches: [production]
+ branches: [staging]
paths:
- "automated_ingestion/**"
# The code location imports db/ models and domain/ rules in-process,
From 976a861e68e4289912e623b1c0f17651dc40b3bc Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 15:44:57 -0700
Subject: [PATCH 076/151] chore(ingestion): support dg for code location
environment variables
The dagster-cloud CLI has no environment-variable command at all; dg does, but
refuses to run outside a directory it recognises as a project. Adding the
tool.dg blocks satisfies that check. It does not affect what the deployed code
location loads, since dagster_cloud.yaml names the entry point explicitly --
verified by loading the definitions and running the suite with the blocks in
place.
The script sets each phase in one pass. Secrets go through --from-local-env
rather than arguments, so they never reach the command line or shell history.
Phases are separate deliberately. `database` waits on ingestion_role.sql having
been run: pointing CLOUD_SQL_* at a role that does not exist makes
database_connectivity fail in a way that looks like the
serverless-to-Cloud-SQL problem it exists to test.
Note for whoever runs this: it needs a Dagster+ *user* token. An agent token
authenticates and returns data for queries, but is unauthorized for these
mutations, and dg surfaces that as a KeyError on its own error handler rather
than as a permission message.
Co-Authored-By: Claude Opus 5
---
.../scripts/set_code_location_env.sh | 70 +++++++++++++++++++
pyproject.toml | 10 +++
2 files changed, 80 insertions(+)
create mode 100755 automated_ingestion/scripts/set_code_location_env.sh
diff --git a/automated_ingestion/scripts/set_code_location_env.sh b/automated_ingestion/scripts/set_code_location_env.sh
new file mode 100755
index 000000000..5ece6926c
--- /dev/null
+++ b/automated_ingestion/scripts/set_code_location_env.sh
@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+# Set every environment variable the ocotillo-automated-ingestion code location
+# needs, in one pass.
+#
+# Requires a Dagster+ *user* token -- an agent token authenticates but is not
+# authorized for these mutations, and dg reports that as an unhelpful KeyError:
+# dg plus config set --api-token 'user:...'
+#
+# Secrets are never passed as arguments. `--from-local-env` reads them from this
+# shell, so nothing sensitive reaches the command line, your shell history, or
+# the Dagster+ audit log's argument capture. Export them first:
+#
+# read -rs "DIVERHUB_USERNAME?Diver-HUB username: "; echo
+# read -rs "DIVERHUB_PASSWORD?Diver-HUB password: "; echo
+# export DIVERHUB_USERNAME DIVERHUB_PASSWORD
+#
+# Usage:
+# ./automated_ingestion/scripts/set_code_location_env.sh storage
+# ./automated_ingestion/scripts/set_code_location_env.sh vendor
+# ./automated_ingestion/scripts/set_code_location_env.sh database
+#
+# The phases are separate on purpose. `database` should wait until
+# automated_ingestion/sql/ingestion_role.sql has been run: setting CLOUD_SQL_*
+# against a role that does not exist yet makes database_connectivity fail in a
+# way that looks like the serverless-to-Cloud-SQL problem it is meant to test.
+set -euo pipefail
+
+DG="uv run --with dagster-dg-cli dg"
+PHASE="${1:-}"
+
+set_var() { echo " $1"; $DG plus create env "$@" --global -y >/dev/null; }
+
+case "$PHASE" in
+storage)
+ echo "Raw-zone buckets (different value per scope):"
+ set_var INGESTION_GCS_BUCKET ocotillo-ingestion-production --scope full
+ set_var INGESTION_GCS_BUCKET ocotillo-ingestion-staging --scope branch
+ ;;
+vendor)
+ : "${DIVERHUB_USERNAME:?export it first, see the header}"
+ : "${DIVERHUB_PASSWORD:?export it first, see the header}"
+ echo "Diver-HUB credentials (values read from this shell, not echoed):"
+ set_var DIVERHUB_USERNAME --from-local-env
+ set_var DIVERHUB_PASSWORD --from-local-env
+ ;;
+database)
+ : "${CLOUD_SQL_INSTANCE_NAME:?export it first}"
+ : "${CLOUD_SQL_DATABASE:?export it first}"
+ echo "Cloud SQL connection:"
+ set_var DB_DRIVER cloudsql
+ set_var CLOUD_SQL_IP_TYPE public
+ set_var CLOUD_SQL_USER ocotillo_ingestion
+ set_var CLOUD_SQL_INSTANCE_NAME --from-local-env
+ set_var CLOUD_SQL_DATABASE --from-local-env
+ # Prefer IAM auth: it removes the password entirely, and ingestion_role.sql
+ # documents creating the role as "ocotillo-ingestion@PROJECT.iam" instead.
+ if [ -n "${CLOUD_SQL_PASSWORD:-}" ]; then
+ set_var CLOUD_SQL_PASSWORD --from-local-env
+ else
+ echo " CLOUD_SQL_PASSWORD unset -- assuming IAM auth"
+ set_var CLOUD_SQL_IAM_AUTH 1
+ fi
+ ;;
+*)
+ echo "usage: $0 {storage|vendor|database}" >&2
+ exit 64
+ ;;
+esac
+
+echo "Done. Verify in Dagster+ under Deployment -> Environment variables."
diff --git a/pyproject.toml b/pyproject.toml
index 0cdc5c86a..b33d0e623 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -131,6 +131,16 @@ packages = [
[tool.dagster]
module_name = "automated_ingestion.defs.definitions"
+# Required by the `dg` CLI, which refuses to run outside a directory it
+# recognises as a project. `dagster_cloud.yaml` names the entry point
+# explicitly, so this does not affect what the deployed code location loads --
+# it only lets `dg plus` manage environment variables from this checkout.
+[tool.dg]
+directory_type = "project"
+
+[tool.dg.project]
+root_module = "automated_ingestion"
+
# Bare `--cov` measures every imported module, which pulls the whole virtualenv
# into the report. Scope it to first-party code instead. Keep this a single
# source root -- listing each package separately makes coverage treat every
From f246d61107f376a23add2856968eda6c5085c70f Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 15:52:13 -0700
Subject: [PATCH 077/151] fix(ingestion): make the IAM database path internally
consistent
CLOUD_SQL_USER means different things in the two authentication modes and
db/engine.py passes it straight to the connector either way. The script set it
to the plain role name unconditionally and then chose IAM auth when no password
was exported, so the two settings contradicted each other -- a combination that
fails as an authentication error looking like a missing grant. It now derives
the value from whichever branch it takes.
The role DDL leads with the IAM role for the same reason, since that is the
configured path, and states the exact string CLOUD_SQL_USER has to match.
IAM authentication also needs GCP-side grants that this configuration did not
create: cloudsql.client, cloudsql.instanceUser, and the service account
registered as a CLOUD_IAM_SERVICE_ACCOUNT database user. Without them the
Postgres role exists but cannot be reached. They are gated on a
cloud_sql_instance variable so the storage half can still be applied before the
database half is decided.
Variables are no longer set with --global. This Dagster+ deployment hosts other
code locations, and deployment-level scope made the vendor and database
credentials readable by all of them.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/iac/main.tf | 37 +++++++++++++++++++
automated_ingestion/iac/variables.tf | 6 +++
.../scripts/set_code_location_env.sh | 28 +++++++++++---
automated_ingestion/sql/ingestion_role.sql | 27 ++++++++++----
4 files changed, 85 insertions(+), 13 deletions(-)
diff --git a/automated_ingestion/iac/main.tf b/automated_ingestion/iac/main.tf
index 9df05f48a..e432f1808 100644
--- a/automated_ingestion/iac/main.tf
+++ b/automated_ingestion/iac/main.tf
@@ -101,3 +101,40 @@ resource "google_storage_bucket_iam_member" "ingestion_object_admin" {
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.ingestion.email}"
}
+
+# Database access for the ingestion service account.
+#
+# Only created when `cloud_sql_instance` is set, so the storage half of this
+# configuration can be applied before the database half is decided.
+#
+# These grants are what make IAM database authentication work. Without them the
+# Postgres role in automated_ingestion/sql/ingestion_role.sql exists but cannot
+# be reached: the connector fails while acquiring a token, which surfaces as an
+# authentication error and reads like a missing GRANT.
+resource "google_project_iam_member" "ingestion_cloudsql_client" {
+ count = var.cloud_sql_instance == null ? 0 : 1
+
+ project = var.project_id
+ role = "roles/cloudsql.client"
+ member = "serviceAccount:${google_service_account.ingestion.email}"
+}
+
+resource "google_project_iam_member" "ingestion_cloudsql_instance_user" {
+ count = var.cloud_sql_instance == null ? 0 : 1
+
+ project = var.project_id
+ role = "roles/cloudsql.instanceUser"
+ member = "serviceAccount:${google_service_account.ingestion.email}"
+}
+
+# Registers the service account as a database user. The Postgres role itself,
+# and its grants, come from ingestion_role.sql -- this only makes the login
+# possible.
+resource "google_sql_user" "ingestion" {
+ count = var.cloud_sql_instance == null ? 0 : 1
+
+ name = trimsuffix(google_service_account.ingestion.email, ".gserviceaccount.com")
+ instance = var.cloud_sql_instance
+ project = var.project_id
+ type = "CLOUD_IAM_SERVICE_ACCOUNT"
+}
diff --git a/automated_ingestion/iac/variables.tf b/automated_ingestion/iac/variables.tf
index c06187f4e..d7440af8c 100644
--- a/automated_ingestion/iac/variables.tf
+++ b/automated_ingestion/iac/variables.tf
@@ -14,3 +14,9 @@ variable "bucket_location" {
description = "Bucket location. US-CENTRAL1 keeps the raw zone in the same region as Cloud SQL, so replay reads do not cross regions."
default = "US-CENTRAL1"
}
+
+variable "cloud_sql_instance" {
+ type = string
+ description = "Cloud SQL instance name for the IAM database user. Leave null to skip the database grants entirely -- useful before the instance is known, or when using password authentication instead."
+ default = null
+}
diff --git a/automated_ingestion/scripts/set_code_location_env.sh b/automated_ingestion/scripts/set_code_location_env.sh
index 5ece6926c..35a263b59 100755
--- a/automated_ingestion/scripts/set_code_location_env.sh
+++ b/automated_ingestion/scripts/set_code_location_env.sh
@@ -14,6 +14,11 @@
# read -rs "DIVERHUB_PASSWORD?Diver-HUB password: "; echo
# export DIVERHUB_USERNAME DIVERHUB_PASSWORD
#
+# Variables are scoped to this code location, not the deployment. If an earlier
+# run set them with --global, delete those deployment-level entries in the
+# Dagster+ UI afterwards -- otherwise both exist and which one wins is not
+# obvious from either place.
+#
# Usage:
# ./automated_ingestion/scripts/set_code_location_env.sh storage
# ./automated_ingestion/scripts/set_code_location_env.sh vendor
@@ -28,7 +33,10 @@ set -euo pipefail
DG="uv run --with dagster-dg-cli dg"
PHASE="${1:-}"
-set_var() { echo " $1"; $DG plus create env "$@" --global -y >/dev/null; }
+# No --global: that sets the variable at deployment level, where every other
+# code location in this deployment can read it. This deployment is shared, so
+# the vendor and database credentials stay scoped to this location.
+set_var() { echo " $1"; $DG plus create env "$@" -y >/dev/null; }
case "$PHASE" in
storage)
@@ -49,16 +57,26 @@ database)
echo "Cloud SQL connection:"
set_var DB_DRIVER cloudsql
set_var CLOUD_SQL_IP_TYPE public
- set_var CLOUD_SQL_USER ocotillo_ingestion
set_var CLOUD_SQL_INSTANCE_NAME --from-local-env
set_var CLOUD_SQL_DATABASE --from-local-env
- # Prefer IAM auth: it removes the password entirely, and ingestion_role.sql
- # documents creating the role as "ocotillo-ingestion@PROJECT.iam" instead.
+
+ # CLOUD_SQL_USER means different things in the two auth modes, and db/engine.py
+ # passes it straight to the connector either way. Under IAM auth it must be the
+ # service account with the .gserviceaccount.com suffix stripped; a plain
+ # Postgres role name there fails as an authentication error that reads like a
+ # missing grant. Deriving it here keeps the two settings from contradicting
+ # each other.
if [ -n "${CLOUD_SQL_PASSWORD:-}" ]; then
+ echo " (password auth)"
+ set_var CLOUD_SQL_IAM_AUTH 0
+ set_var CLOUD_SQL_USER ocotillo_ingestion
set_var CLOUD_SQL_PASSWORD --from-local-env
else
- echo " CLOUD_SQL_PASSWORD unset -- assuming IAM auth"
+ IAM_SA="${INGESTION_SERVICE_ACCOUNT:-ocotillo-ingestion@waterdatainitiative-271000.iam.gserviceaccount.com}"
+ IAM_USER="${IAM_SA%.gserviceaccount.com}"
+ echo " (IAM auth as ${IAM_USER})"
set_var CLOUD_SQL_IAM_AUTH 1
+ set_var CLOUD_SQL_USER "$IAM_USER"
fi
;;
*)
diff --git a/automated_ingestion/sql/ingestion_role.sql b/automated_ingestion/sql/ingestion_role.sql
index 65afb657b..990b9a5e1 100644
--- a/automated_ingestion/sql/ingestion_role.sql
+++ b/automated_ingestion/sql/ingestion_role.sql
@@ -11,16 +11,27 @@
-- and NMW_* tables, so a bug in an adapter cannot corrupt data no ingestion
-- path should ever reach.
--- Set the password out of band; do not commit it. It belongs in Secret
--- Manager alongside internal-ogc-api-keys.
--- CREATE ROLE ocotillo_ingestion LOGIN PASSWORD '...';
+-- IAM authentication is the configured path, and the reason is that it removes
+-- the credential rather than rotating it: Cloud SQL mints a short-lived token
+-- from the service account, so there is no password to store in Dagster+, in
+-- Secret Manager, or here.
+--
+-- The role name is the service account with the .gserviceaccount.com suffix
+-- stripped. That exact string is also what CLOUD_SQL_USER must be set to --
+-- db/engine.py passes it straight to the connector, and a plain role name there
+-- fails as an authentication error that reads like a missing grant.
+--
+-- CREATE ROLE "ocotillo-ingestion@waterdatainitiative-271000.iam" WITH LOGIN;
+-- GRANT cloudsqliamuser TO "ocotillo-ingestion@waterdatainitiative-271000.iam";
--
--- Or, preferred, use IAM database authentication and create the role for the
--- service account instead, so there is no password to rotate:
--- CREATE ROLE "ocotillo-ingestion@PROJECT.iam" WITH LOGIN;
--- GRANT cloudsqliamuser TO "ocotillo-ingestion@PROJECT.iam";
+-- Password authentication, if IAM is ever unavailable. Set the password out of
+-- band and store it in Secret Manager; never commit it, and set
+-- CLOUD_SQL_IAM_AUTH=0 so the two settings agree.
+--
+-- CREATE ROLE ocotillo_ingestion LOGIN PASSWORD '...';
-\set role_name ocotillo_ingestion
+-- Set to match whichever role was created above.
+\set role_name "ocotillo-ingestion@waterdatainitiative-271000.iam"
GRANT CONNECT ON DATABASE :"db_name" TO :"role_name";
GRANT USAGE ON SCHEMA public TO :"role_name";
From a4259545fd47344f4e8262178198e85463c9b0bb Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 15:55:57 -0700
Subject: [PATCH 078/151] fix(ingestion): make db and domain importable in the
deployed image
database_connectivity failed in Dagster+ with ModuleNotFoundError: No module
named 'db', while the code location itself loaded and ingestion_heartbeat ran.
The image copies the repository to /opt/dagster/app but never installs it: the
generated requirements omit the project, and the build template only runs
`pip install .` when a setup.py exists. So db and domain are importable only
while that directory is on sys.path -- true when Dagster loads the code
location, not guaranteed in the separate process that executes a step, which is
exactly where the loader's lazy imports run.
Locally an editable install puts the repository on sys.path unconditionally,
which is why 43 tests pass and the failure appeared only once deployed. Two
tests now cover it: one asserts the path entry exists, the other imports db
from a process whose working directory is not the repository.
Importing automated_ingestion now adds the repository root itself, so the
coupling is satisfied wherever the package is imported from rather than
depending on how it was launched.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/__init__.py | 17 +++++++++
.../tests/test_connectivity.py | 36 +++++++++++++++++++
2 files changed, 53 insertions(+)
diff --git a/automated_ingestion/__init__.py b/automated_ingestion/__init__.py
index 1fb584ba5..de197f548 100644
--- a/automated_ingestion/__init__.py
+++ b/automated_ingestion/__init__.py
@@ -28,6 +28,23 @@
the first source; ``shared/`` holds what later sources reuse.
See ``docs/automated-ingestion-pipeline-plan.md``.
+
+Importing this package puts the repository root on ``sys.path``. That is
+unusual and deliberate. The Dagster+ image copies the repository to
+``/opt/dagster/app`` but never installs it -- the generated requirements omit
+the project, and the build template only runs ``pip install .`` when a
+``setup.py`` exists -- so ``db`` and ``domain`` are importable only while that
+directory happens to be on the path. It is, when Dagster loads the code
+location; it is not guaranteed in the separate process that executes a step,
+which is where the loader's imports actually run. Locally the editable install
+hides the difference entirely, so the failure appears only once deployed.
"""
+import sys as _sys
+from pathlib import Path as _Path
+
+_REPOSITORY_ROOT = _Path(__file__).resolve().parent.parent
+if str(_REPOSITORY_ROOT) not in _sys.path:
+ _sys.path.insert(0, str(_REPOSITORY_ROOT))
+
# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_connectivity.py b/automated_ingestion/tests/test_connectivity.py
index da5453139..35d4d83ae 100644
--- a/automated_ingestion/tests/test_connectivity.py
+++ b/automated_ingestion/tests/test_connectivity.py
@@ -57,4 +57,40 @@ def test_loading_definitions_does_not_import_db_engine():
assert result.stdout.strip() == "False", result.stdout
+def test_importing_the_package_makes_the_repository_importable():
+ # The deployed image never installs this project, so `db` and `domain`
+ # resolve only if the repository root is on sys.path. Locally an editable
+ # install provides that and hides the difference, which is why this failed
+ # only once deployed -- the code location loaded fine and the step that
+ # imported db died.
+ import sys
+
+ import automated_ingestion
+
+ assert str(automated_ingestion._REPOSITORY_ROOT) in sys.path
+
+
+def test_db_imports_from_an_unrelated_working_directory():
+ # Reproduces the deployed condition: a process whose cwd is not the
+ # repository. The lazy imports in the resource and the connectivity asset
+ # run at step execution, not at load, so this is the path that broke.
+ import subprocess
+ import sys
+
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ "import automated_ingestion; "
+ "from db.transducer import TransducerObservation; "
+ "print(TransducerObservation.__tablename__)",
+ ],
+ capture_output=True,
+ text=True,
+ cwd="/",
+ )
+ assert result.returncode == 0, result.stderr
+ assert "transducer_observation" in result.stdout
+
+
# ============= EOF =============================================
From 1df7d8c63a85ce7a48146fd9ba31159a2c32e06c Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 16:16:37 -0700
Subject: [PATCH 079/151] fix(ingestion): set PYTHONPATH and report the import
environment
Inserting the repository root from automated_ingestion/__init__.py did not fix
the ModuleNotFoundError for db in a step process, so the assumption behind that
fix was wrong somewhere I cannot see from here.
PYTHONPATH=/opt/dagster/app makes the app root importable regardless of how a
process was launched, rather than depending on the package having been imported
first or on the working directory being on the path.
The heartbeat asset now reports cwd, the resolved app root and its contents,
whether db and domain are findable, and sys.path. It needs no credentials, so
it reports even when everything else fails -- which is what a diagnostic asset
is for. If PYTHONPATH does not resolve this, its metadata says why.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/defs/assets/heartbeat.py | 38 +++++++++++++++++--
.../scripts/set_code_location_env.sh | 9 +++++
2 files changed, 43 insertions(+), 4 deletions(-)
diff --git a/automated_ingestion/defs/assets/heartbeat.py b/automated_ingestion/defs/assets/heartbeat.py
index 2fe21ee28..8e78157d8 100644
--- a/automated_ingestion/defs/assets/heartbeat.py
+++ b/automated_ingestion/defs/assets/heartbeat.py
@@ -24,18 +24,48 @@
from datetime import datetime, timezone
-from dagster import AssetExecutionContext, asset
+from dagster import AssetExecutionContext, MetadataValue, Output, asset
@asset(
group_name="operations",
description="Static heartbeat proving the code location loaded and can run.",
)
-def ingestion_heartbeat(context: AssetExecutionContext) -> str:
- """Return the materialization timestamp."""
+def ingestion_heartbeat(context: AssetExecutionContext) -> Output[str]:
+ """Return the materialization timestamp, with the import environment.
+
+ The environment metadata is here because a step process is not the process
+ that loaded the code location, and the two do not necessarily agree about
+ sys.path. When an import that works at load time fails at execution, this is
+ the asset that says why -- it runs without credentials, so it reports even
+ when everything else is broken.
+ """
+ import os
+ import sys
+ from importlib.util import find_spec
+
stamp = datetime.now(timezone.utc).isoformat()
context.log.info("automated_ingestion code location alive at %s", stamp)
- return stamp
+
+ app_root = os.path.dirname(
+ os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ )
+ try:
+ siblings = sorted(os.listdir(app_root))
+ except OSError as exc:
+ siblings = [f""]
+
+ return Output(
+ stamp,
+ metadata={
+ "cwd": MetadataValue.text(os.getcwd()),
+ "app_root": MetadataValue.text(app_root),
+ "app_root_contents": MetadataValue.text(", ".join(siblings)),
+ "db_on_path": MetadataValue.bool(find_spec("db") is not None),
+ "domain_on_path": MetadataValue.bool(find_spec("domain") is not None),
+ "sys_path": MetadataValue.json(sys.path),
+ },
+ )
# ============= EOF =============================================
diff --git a/automated_ingestion/scripts/set_code_location_env.sh b/automated_ingestion/scripts/set_code_location_env.sh
index 35a263b59..7d11f647e 100755
--- a/automated_ingestion/scripts/set_code_location_env.sh
+++ b/automated_ingestion/scripts/set_code_location_env.sh
@@ -40,6 +40,15 @@ set_var() { echo " $1"; $DG plus create env "$@" -y >/dev/null; }
case "$PHASE" in
storage)
+ # The image copies the repository to /opt/dagster/app but never installs it,
+ # so db/ and domain/ are importable only if that directory is on the path.
+ # The process that loads the code location has it; the process that executes a
+ # step does not reliably, which shows up as ModuleNotFoundError for db at
+ # execution while the location itself loads fine. Setting PYTHONPATH removes
+ # the guesswork instead of depending on how each process was launched.
+ echo "Import path:"
+ set_var PYTHONPATH /opt/dagster/app
+
echo "Raw-zone buckets (different value per scope):"
set_var INGESTION_GCS_BUCKET ocotillo-ingestion-production --scope full
set_var INGESTION_GCS_BUCKET ocotillo-ingestion-staging --scope branch
From 625b142ed44d5c53214b7263e8811a0d2142d581 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 16:30:30 -0700
Subject: [PATCH 080/151] chore(ci): keep .git out of the Dagster+ build
context
Every deploy was transferring 316 MB before the first layer could build. The
tracked tree is 13 MB; the rest was almost entirely .git, which CI clones with
full history and which nothing in the image reads. The context is now 6.7 MB.
Also excluded: Python caches, which are worse than useless in an image built on
a pinned base; test fixtures and BDD features, since the image runs the code
location and CI runs the suite outside it; and transfers/data, logs, and
metrics, which are untracked, machine-specific, and roughly 900 MB on a
developer checkout -- a local `docker build` was shipping all of it.
Verified by building the real image with the new context: db, domain, services,
core, schemas, automated_ingestion, and alembic are all present, db and domain
import, and the code location resolves all four assets.
Co-Authored-By: Claude Opus 5
---
.dockerignore | 46 +++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 45 insertions(+), 1 deletion(-)
diff --git a/.dockerignore b/.dockerignore
index b694934fb..95b01de27 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1 +1,45 @@
-.venv
\ No newline at end of file
+# Keep the build context to what the image actually runs.
+#
+# The tracked tree is ~13 MB; the context was ~316 MB, almost entirely .git.
+# CI clones full history, and every Dagster+ deploy was transferring it before
+# the first layer could build.
+
+# Version control. Nothing in the image reads git metadata.
+.git
+.github
+.gitignore
+
+# Python build and cache artifacts. Stale .pyc from a different interpreter is
+# worse than useless in an image built on a pinned base.
+__pycache__/
+*.py[cod]
+*.egg-info/
+.pytest_cache/
+.ruff_cache/
+.mypy_cache/
+.coverage
+htmlcov/
+
+# Virtualenvs and local tooling state.
+.venv
+.dg
+*.tfstate
+*.tfstate.*
+.terraform/
+
+# Local-only data from legacy transfer runs. Untracked, machine-specific, and
+# the largest thing on a developer checkout by an order of magnitude -- a local
+# `docker build` would otherwise ship ~900 MB of CSV cache.
+transfers/data/
+transfers/logs/
+transfers/metrics/
+
+# Test fixtures and BDD features. The image runs the code location, not the
+# suite; CI runs the suite outside the image.
+tests/
+features/
+
+# Editor and OS noise.
+.DS_Store
+.idea/
+.vscode/
From 7795959b6d3fd4f8511f54426329d5fd0ac8b603 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 19:57:16 -0700
Subject: [PATCH 081/151] fix(ingestion): install the repository into the
Dagster+ image
database_connectivity failed with ModuleNotFoundError: No module named 'db'.
The heartbeat diagnostic settled why: inside the running container the working
directory is /opt/dagster/app and db/ is present in it, but find_spec reports
db and domain as not findable -- that directory is not on sys.path in the
process that executes a step.
My two previous attempts treated the symptom. Inserting the repository root
from automated_ingestion/__init__.py assumed the package's own location could
bootstrap the path, and setting PYTHONPATH assumed the variable reached that
process. Neither worked, and both left the image depending on path luck.
The image now installs the repository via the post-install hook the build
template already provides, so db, domain, services, core, and schemas resolve
from site-packages regardless of working directory, PYTHONPATH, or which
process is importing. --no-deps because the pinned, hashed requirements are
already installed and this must not resolve on top of them.
Verified in a locally built image: with cwd set to / and no PYTHONPATH, db and
domain are findable, db resolves from site-packages, and both import.
The sys.path insert is removed rather than left as redundant insurance -- it
encoded a theory that turned out to be wrong, and keeping it would suggest the
mechanism still matters.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/__init__.py | 23 ++++++-------------
.../tests/test_connectivity.py | 15 ++----------
dagster_cloud_post_install.sh | 15 ++++++++++++
3 files changed, 24 insertions(+), 29 deletions(-)
create mode 100755 dagster_cloud_post_install.sh
diff --git a/automated_ingestion/__init__.py b/automated_ingestion/__init__.py
index de197f548..401409576 100644
--- a/automated_ingestion/__init__.py
+++ b/automated_ingestion/__init__.py
@@ -29,22 +29,13 @@
See ``docs/automated-ingestion-pipeline-plan.md``.
-Importing this package puts the repository root on ``sys.path``. That is
-unusual and deliberate. The Dagster+ image copies the repository to
-``/opt/dagster/app`` but never installs it -- the generated requirements omit
-the project, and the build template only runs ``pip install .`` when a
-``setup.py`` exists -- so ``db`` and ``domain`` are importable only while that
-directory happens to be on the path. It is, when Dagster loads the code
-location; it is not guaranteed in the separate process that executes a step,
-which is where the loader's imports actually run. Locally the editable install
-hides the difference entirely, so the failure appears only once deployed.
+The image installs this repository as a package (see
+``dagster_cloud_post_install.sh``), so ``db``, ``domain``, and the rest resolve
+from site-packages rather than from whatever happens to be on ``sys.path``. That
+matters because the process that loads the code location and the process that
+executes a step do not agree about the path, and the loader's imports run in the
+second one. Locally an editable install produces the same result, which is why
+the difference is invisible until deployment.
"""
-import sys as _sys
-from pathlib import Path as _Path
-
-_REPOSITORY_ROOT = _Path(__file__).resolve().parent.parent
-if str(_REPOSITORY_ROOT) not in _sys.path:
- _sys.path.insert(0, str(_REPOSITORY_ROOT))
-
# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_connectivity.py b/automated_ingestion/tests/test_connectivity.py
index 35d4d83ae..622f9f1e7 100644
--- a/automated_ingestion/tests/test_connectivity.py
+++ b/automated_ingestion/tests/test_connectivity.py
@@ -57,23 +57,12 @@ def test_loading_definitions_does_not_import_db_engine():
assert result.stdout.strip() == "False", result.stdout
-def test_importing_the_package_makes_the_repository_importable():
- # The deployed image never installs this project, so `db` and `domain`
- # resolve only if the repository root is on sys.path. Locally an editable
- # install provides that and hides the difference, which is why this failed
- # only once deployed -- the code location loaded fine and the step that
- # imported db died.
- import sys
-
- import automated_ingestion
-
- assert str(automated_ingestion._REPOSITORY_ROOT) in sys.path
-
-
def test_db_imports_from_an_unrelated_working_directory():
# Reproduces the deployed condition: a process whose cwd is not the
# repository. The lazy imports in the resource and the connectivity asset
# run at step execution, not at load, so this is the path that broke.
+ # In the image this passes because the repository is installed; locally
+ # because the editable install has the same effect.
import subprocess
import sys
diff --git a/dagster_cloud_post_install.sh b/dagster_cloud_post_install.sh
new file mode 100755
index 000000000..c093ae1c8
--- /dev/null
+++ b/dagster_cloud_post_install.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Runs inside the Dagster+ image build, after the repository has been copied to
+# /opt/dagster/app and the pinned requirements installed.
+#
+# Installs this repository as a package so `db`, `domain`, `services`, `core`,
+# and `schemas` resolve from site-packages. Without it they are importable only
+# while /opt/dagster/app happens to be on sys.path -- true for the process that
+# loads the code location, not for the process that executes a step, which is
+# where the loader's imports run. That difference is invisible locally, where an
+# editable install puts the repository on the path unconditionally.
+#
+# --no-deps because the pinned, hashed requirements are already installed and
+# this must not resolve anything on top of them.
+set -euo pipefail
+pip install --no-deps .
From 2799f9188f20770cc8d14540a0443a70dca11632 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 20:11:52 -0700
Subject: [PATCH 082/151] fix(ingestion): supply GCP credentials in a runtime
that has none
With the import fixed, database_connectivity reaches the Cloud SQL connector
and fails on DefaultCredentialsError. Dagster+ Serverless runs outside GCP, so
there is no metadata server and google.auth.default() finds nothing.
The service account key travels as a Dagster+ secret and is written to a file
at runtime, because GOOGLE_APPLICATION_CREDENTIALS names a path rather than
holding a value. The file is mode 600 in the process temporary directory, which
the container discards with the run.
Called before importing db.engine rather than after: that module builds its
connector at import time and resolves credentials right then, so doing it
afterwards would be too late. The dlt pipeline calls it too -- gcsfs resolves
credentials the same way, so the raw zone would have failed identically once
the loader got that far.
Existing credentials are never shadowed, so a developer's gcloud login is used
as-is. An unset key is not an error: locally google.auth finds its own, and in
Serverless it fails loudly, which is correct in both cases. A path supplied
instead of the key itself is rejected with a message saying so, since that
mistake would otherwise surface deep inside google.auth.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/defs/resources.py | 10 +++
.../scripts/set_code_location_env.sh | 13 ++-
automated_ingestion/shared/credentials.py | 83 +++++++++++++++++
.../sources/san_acacia/dlt_pipeline.py | 8 ++
automated_ingestion/tests/test_credentials.py | 88 +++++++++++++++++++
5 files changed, 201 insertions(+), 1 deletion(-)
create mode 100644 automated_ingestion/shared/credentials.py
create mode 100644 automated_ingestion/tests/test_credentials.py
diff --git a/automated_ingestion/defs/resources.py b/automated_ingestion/defs/resources.py
index 6e11316d8..25172bfa1 100644
--- a/automated_ingestion/defs/resources.py
+++ b/automated_ingestion/defs/resources.py
@@ -39,6 +39,16 @@ class OcotilloDatabase(ConfigurableResource):
@contextmanager
def session(self) -> Iterator[object]:
"""Yield a SQLAlchemy session, rolled back and closed on the way out."""
+ # Credentials first: db.engine builds its Cloud SQL connector at import
+ # time, and the connector resolves Application Default Credentials right
+ # then. Serverless has none until they are written to disk, so doing this
+ # afterwards would be too late.
+ from automated_ingestion.shared.credentials import (
+ ensure_application_default_credentials,
+ )
+
+ ensure_application_default_credentials()
+
# Imported lazily: importing db.engine builds an engine from the
# environment at import time, which should happen when a run asks for a
# session, not when Dagster loads the code location to list assets.
diff --git a/automated_ingestion/scripts/set_code_location_env.sh b/automated_ingestion/scripts/set_code_location_env.sh
index 7d11f647e..e4e872d8c 100755
--- a/automated_ingestion/scripts/set_code_location_env.sh
+++ b/automated_ingestion/scripts/set_code_location_env.sh
@@ -21,6 +21,7 @@
#
# Usage:
# ./automated_ingestion/scripts/set_code_location_env.sh storage
+# ./automated_ingestion/scripts/set_code_location_env.sh credentials
# ./automated_ingestion/scripts/set_code_location_env.sh vendor
# ./automated_ingestion/scripts/set_code_location_env.sh database
#
@@ -53,6 +54,16 @@ storage)
set_var INGESTION_GCS_BUCKET ocotillo-ingestion-production --scope full
set_var INGESTION_GCS_BUCKET ocotillo-ingestion-staging --scope branch
;;
+credentials)
+ : "${INGESTION_GCP_CREDENTIALS_JSON:?export the service account key JSON, not a path}"
+ # Serverless runs outside GCP, so there is no metadata server and nothing
+ # supplies Application Default Credentials. Both the Cloud SQL connector and
+ # gcsfs need them. Mint the key with:
+ # gcloud iam service-accounts keys create /dev/stdout \
+ # --iam-account ocotillo-ingestion@waterdatainitiative-271000.iam.gserviceaccount.com
+ echo "GCP credentials (key JSON read from this shell, not echoed):"
+ set_var INGESTION_GCP_CREDENTIALS_JSON --from-local-env
+ ;;
vendor)
: "${DIVERHUB_USERNAME:?export it first, see the header}"
: "${DIVERHUB_PASSWORD:?export it first, see the header}"
@@ -89,7 +100,7 @@ database)
fi
;;
*)
- echo "usage: $0 {storage|vendor|database}" >&2
+ echo "usage: $0 {storage|credentials|vendor|database}" >&2
exit 64
;;
esac
diff --git a/automated_ingestion/shared/credentials.py b/automated_ingestion/shared/credentials.py
new file mode 100644
index 000000000..cc108ba84
--- /dev/null
+++ b/automated_ingestion/shared/credentials.py
@@ -0,0 +1,83 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Application Default Credentials for a runtime that has none.
+
+Dagster+ Serverless runs outside GCP, so there is no metadata server to supply
+credentials. Anything reaching Google -- the Cloud SQL connector for the loader,
+gcsfs for the raw zone -- calls ``google.auth.default()`` and fails with
+``DefaultCredentialsError`` unless something has put credentials on disk first.
+
+The service account key therefore travels as a Dagster+ secret and is written to
+a file here, because ``GOOGLE_APPLICATION_CREDENTIALS`` names a path rather than
+holding a value. The file lands in the process's temporary directory, which the
+container discards when the run ends.
+"""
+
+import json
+import os
+import tempfile
+
+CREDENTIALS_ENV_VAR = "INGESTION_GCP_CREDENTIALS_JSON"
+"""Service account key JSON, as a Dagster+ secret. Never committed."""
+
+_ADC_ENV_VAR = "GOOGLE_APPLICATION_CREDENTIALS"
+
+_written_path: str | None = None
+
+
+def ensure_application_default_credentials() -> str | None:
+ """Materialize ADC from the environment, returning the path if written.
+
+ Idempotent, and does nothing when credentials already exist -- locally that
+ means a developer's gcloud login is used as-is rather than being shadowed.
+ """
+ global _written_path
+
+ existing = os.environ.get(_ADC_ENV_VAR, "").strip()
+ if existing:
+ return existing
+ if _written_path is not None:
+ return _written_path
+
+ raw = os.environ.get(CREDENTIALS_ENV_VAR, "").strip()
+ if not raw:
+ # No key configured. Leave google.auth to its own discovery, which
+ # succeeds on a developer machine and fails loudly in Serverless -- the
+ # right outcome in both cases.
+ return None
+
+ try:
+ parsed = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise RuntimeError(
+ f"{CREDENTIALS_ENV_VAR} is set but is not valid JSON. It must hold the "
+ "service account key itself, not a path to one."
+ ) from exc
+
+ handle = tempfile.NamedTemporaryFile(
+ mode="w", suffix=".json", prefix="ingestion-adc-", delete=False
+ )
+ with handle as fh:
+ json.dump(parsed, fh)
+ os.chmod(handle.name, 0o600)
+
+ os.environ[_ADC_ENV_VAR] = handle.name
+ _written_path = handle.name
+ return handle.name
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/sources/san_acacia/dlt_pipeline.py b/automated_ingestion/sources/san_acacia/dlt_pipeline.py
index c7b9e7319..25b08a270 100644
--- a/automated_ingestion/sources/san_acacia/dlt_pipeline.py
+++ b/automated_ingestion/sources/san_acacia/dlt_pipeline.py
@@ -173,6 +173,14 @@ def _approved_timestamps(
def build_pipeline(environment: str) -> Any:
"""A dlt pipeline writing parquet to the raw zone for one environment."""
+ # gcsfs resolves Application Default Credentials the same way the Cloud SQL
+ # connector does, and Serverless supplies none of its own.
+ from automated_ingestion.shared.credentials import (
+ ensure_application_default_credentials,
+ )
+
+ ensure_application_default_credentials()
+
return dlt.pipeline(
pipeline_name=f"san_acacia_{environment}",
destination=dlt.destinations.filesystem(
diff --git a/automated_ingestion/tests/test_credentials.py b/automated_ingestion/tests/test_credentials.py
new file mode 100644
index 000000000..c7e788f88
--- /dev/null
+++ b/automated_ingestion/tests/test_credentials.py
@@ -0,0 +1,88 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Credential materialization for a runtime with no metadata server.
+
+The failure this prevents is not subtle -- DefaultCredentialsError -- but it
+only appears in Serverless, so the tests stand in for a deployment.
+"""
+
+import json
+import os
+
+import pytest
+
+from automated_ingestion.shared import credentials
+from automated_ingestion.shared.credentials import (
+ CREDENTIALS_ENV_VAR,
+ ensure_application_default_credentials,
+)
+
+KEY = {"type": "service_account", "project_id": "waterdatainitiative-271000"}
+
+
+@pytest.fixture(autouse=True)
+def _clean(monkeypatch):
+ monkeypatch.setattr(credentials, "_written_path", None)
+ monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
+ monkeypatch.delenv(CREDENTIALS_ENV_VAR, raising=False)
+
+
+def test_existing_credentials_are_left_alone(monkeypatch):
+ # A developer's gcloud login must not be shadowed by a key in the
+ # environment; whatever is already configured wins.
+ monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/existing/adc.json")
+ monkeypatch.setenv(CREDENTIALS_ENV_VAR, json.dumps(KEY))
+ assert ensure_application_default_credentials() == "/existing/adc.json"
+
+
+def test_no_key_configured_is_not_an_error(monkeypatch):
+ # Locally this is normal -- google.auth finds its own credentials. In
+ # Serverless it fails later, loudly, which is the correct outcome.
+ assert ensure_application_default_credentials() is None
+ assert "GOOGLE_APPLICATION_CREDENTIALS" not in os.environ
+
+
+def test_key_is_written_and_pointed_at(monkeypatch):
+ monkeypatch.setenv(CREDENTIALS_ENV_VAR, json.dumps(KEY))
+ path = ensure_application_default_credentials()
+ assert path and os.path.exists(path)
+ assert os.environ["GOOGLE_APPLICATION_CREDENTIALS"] == path
+ with open(path) as fh:
+ assert json.load(fh) == KEY
+
+
+def test_key_file_is_not_world_readable(monkeypatch):
+ monkeypatch.setenv(CREDENTIALS_ENV_VAR, json.dumps(KEY))
+ path = ensure_application_default_credentials()
+ assert oct(os.stat(path).st_mode)[-3:] == "600"
+
+
+def test_repeated_calls_write_once(monkeypatch):
+ monkeypatch.setenv(CREDENTIALS_ENV_VAR, json.dumps(KEY))
+ first = ensure_application_default_credentials()
+ assert ensure_application_default_credentials() == first
+
+
+def test_a_path_instead_of_a_key_is_rejected(monkeypatch):
+ # Setting the variable to a filename is the obvious mistake, and it would
+ # otherwise fail much later inside google.auth.
+ monkeypatch.setenv(CREDENTIALS_ENV_VAR, "/path/to/key.json")
+ with pytest.raises(RuntimeError, match="not valid JSON"):
+ ensure_application_default_credentials()
+
+
+# ============= EOF =============================================
From affa35c3e0f5504cf4d85588f73e91a48bc21d50 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 20:35:10 -0700
Subject: [PATCH 083/151] fix(ingestion): set code location env vars at a scope
that reaches the container
Dropping --global scoped variables to `OcotilloAPI` -- the project name -- rather
than to `ocotillo-automated-ingestion`, the name in dagster_cloud.yaml. Dagster+
accepts the unknown location without complaint, so INGESTION_GCP_CREDENTIALS_JSON
appeared correctly set in the UI while the container never received it, and the
asset kept failing with DefaultCredentialsError as though no key had been set.
`code_location_name` in [tool.dg.project] looks like the fix and is ignored for
this command, so that avenue is closed and the attempt is not left in the file.
Back to --global, which is broader than ideal: this deployment also hosts
aqueduct_dagster_defs_definitions and die-orchestration, which can read these
values. That is a real cost, recorded in the script along with the alternative --
scoping in the Dagster+ UI against the correct location name -- rather than
traded away silently. A credential the container cannot read is worth less than
one scoped more broadly than we would like.
Co-Authored-By: Claude Opus 5
---
.../scripts/set_code_location_env.sh | 27 +++++++++++++------
pyproject.toml | 1 +
2 files changed, 20 insertions(+), 8 deletions(-)
diff --git a/automated_ingestion/scripts/set_code_location_env.sh b/automated_ingestion/scripts/set_code_location_env.sh
index e4e872d8c..49015dc78 100755
--- a/automated_ingestion/scripts/set_code_location_env.sh
+++ b/automated_ingestion/scripts/set_code_location_env.sh
@@ -14,10 +14,9 @@
# read -rs "DIVERHUB_PASSWORD?Diver-HUB password: "; echo
# export DIVERHUB_USERNAME DIVERHUB_PASSWORD
#
-# Variables are scoped to this code location, not the deployment. If an earlier
-# run set them with --global, delete those deployment-level entries in the
-# Dagster+ UI afterwards -- otherwise both exist and which one wins is not
-# obvious from either place.
+# Variables are set at deployment scope. See the comment on set_var for why
+# location scoping through dg does not work, and what to do instead if these
+# values must not be visible to the other code locations in this deployment.
#
# Usage:
# ./automated_ingestion/scripts/set_code_location_env.sh storage
@@ -34,10 +33,22 @@ set -euo pipefail
DG="uv run --with dagster-dg-cli dg"
PHASE="${1:-}"
-# No --global: that sets the variable at deployment level, where every other
-# code location in this deployment can read it. This deployment is shared, so
-# the vendor and database credentials stay scoped to this location.
-set_var() { echo " $1"; $DG plus create env "$@" -y >/dev/null; }
+# --global sets the variable at deployment level. That is broader than ideal --
+# this deployment also hosts aqueduct_dagster_defs_definitions and
+# die-orchestration, which can then read these values -- but it is the scope
+# that actually reaches the container.
+#
+# Location scoping through dg does not work here: dg names the location from the
+# project (`OcotilloAPI`), not from `location_name` in dagster_cloud.yaml
+# (`ocotillo-automated-ingestion`), and `code_location_name` in [tool.dg.project]
+# is ignored for this command. Dagster+ accepts the unknown name without
+# complaint, so the variable shows as set in the UI and is absent in the
+# container -- which costs an afternoon to work out from a
+# DefaultCredentialsError.
+#
+# To scope properly, set the variable in the Dagster+ UI against
+# `ocotillo-automated-ingestion` instead.
+set_var() { echo " $1"; $DG plus create env "$@" --global -y >/dev/null; }
case "$PHASE" in
storage)
diff --git a/pyproject.toml b/pyproject.toml
index b33d0e623..0fc90ce14 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -141,6 +141,7 @@ directory_type = "project"
[tool.dg.project]
root_module = "automated_ingestion"
+
# Bare `--cov` measures every imported module, which pulls the whole virtualenv
# into the report. Scope it to first-party code instead. Keep this a single
# source root -- listing each package separately makes coverage treat every
From a178f90132cacabbc09ccd4cb77055defc48d839 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 20:36:36 -0700
Subject: [PATCH 084/151] fix(ingestion): reject a bare Cloud SQL instance name
CLOUD_SQL_INSTANCE_NAME was set to `dataservices`, which every layer accepts
until the connector tries to use it and raises a ValueError from inside the
driver, well below anything this project wrote. The connector wants the full
connection name, PROJECT:REGION:INSTANCE.
The script now checks the shape before setting it, and points at the gcloud
command that prints the right value.
Co-Authored-By: Claude Opus 5
---
.../scripts/set_code_location_env.sh | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/automated_ingestion/scripts/set_code_location_env.sh b/automated_ingestion/scripts/set_code_location_env.sh
index 49015dc78..c6cd1de70 100755
--- a/automated_ingestion/scripts/set_code_location_env.sh
+++ b/automated_ingestion/scripts/set_code_location_env.sh
@@ -83,8 +83,22 @@ vendor)
set_var DIVERHUB_PASSWORD --from-local-env
;;
database)
- : "${CLOUD_SQL_INSTANCE_NAME:?export it first}"
+ : "${CLOUD_SQL_INSTANCE_NAME:?export it first, as PROJECT:REGION:INSTANCE}"
: "${CLOUD_SQL_DATABASE:?export it first}"
+
+ # The connector wants the full connection name, not the instance name. A bare
+ # name is accepted by everything up to the point of connecting and then fails
+ # with a ValueError from deep inside the driver, several layers below anything
+ # this project wrote. Catch it here instead.
+ # gcloud sql instances list --format='value(name,connectionName)'
+ case "$CLOUD_SQL_INSTANCE_NAME" in
+ *:*:*) ;;
+ *)
+ echo "error: CLOUD_SQL_INSTANCE_NAME must be PROJECT:REGION:INSTANCE," >&2
+ echo " got '${CLOUD_SQL_INSTANCE_NAME}'." >&2
+ exit 65
+ ;;
+ esac
echo "Cloud SQL connection:"
set_var DB_DRIVER cloudsql
set_var CLOUD_SQL_IP_TYPE public
From af78d18b5c0f3fb8c5de065265b0c919b543323a Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 20:38:57 -0700
Subject: [PATCH 085/151] chore(ingestion): put the raw zone in us-west4,
beside Cloud SQL
The buckets were created in US-CENTRAL1 from a default chosen before the
database region was known. The dataservices instance is in us-west4, so every
Mode B replay would have read across regions and paid egress to reach the
loader.
Bucket location is immutable, so applying this replaces both buckets. Verified
empty first -- zero objects in each -- which makes now the only cheap moment to
do it. After a backfill has landed, moving regions means copying objects and
re-pointing the pipeline rather than editing a variable, which the comment on
the resource now says.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/iac/.terraform.tfstate.lock.info | 1 +
automated_ingestion/iac/main.tf | 9 +++++++--
automated_ingestion/iac/variables.tf | 4 ++--
3 files changed, 10 insertions(+), 4 deletions(-)
create mode 100644 automated_ingestion/iac/.terraform.tfstate.lock.info
diff --git a/automated_ingestion/iac/.terraform.tfstate.lock.info b/automated_ingestion/iac/.terraform.tfstate.lock.info
new file mode 100644
index 000000000..b3e86bd78
--- /dev/null
+++ b/automated_ingestion/iac/.terraform.tfstate.lock.info
@@ -0,0 +1 @@
+{"ID":"dbce0c93-cc58-cb45-fa55-faf792ad5025","Operation":"OperationTypeApply","Info":"","Who":"jakeross@Jakes-MacBook-Pro.local","Version":"1.14.8","Created":"2026-08-19T03:37:52.057173Z","Path":"terraform.tfstate"}
\ No newline at end of file
diff --git a/automated_ingestion/iac/main.tf b/automated_ingestion/iac/main.tf
index e432f1808..47bd0dac3 100644
--- a/automated_ingestion/iac/main.tf
+++ b/automated_ingestion/iac/main.tf
@@ -30,8 +30,13 @@ locals {
resource "google_storage_bucket" "ingestion_raw" {
for_each = local.environments
- name = "ocotillo-ingestion-${each.key}"
- project = var.project_id
+ name = "ocotillo-ingestion-${each.key}"
+ project = var.project_id
+
+ # Bucket location is immutable: changing it replaces the bucket. That is
+ # tolerable only while the raw zone is empty. Once a backfill has landed,
+ # moving regions means copying objects across and re-pointing the pipeline,
+ # not editing this line.
location = var.bucket_location
# The raw zone is the replay source for Mode B backfill: reprocessing a
diff --git a/automated_ingestion/iac/variables.tf b/automated_ingestion/iac/variables.tf
index d7440af8c..2fae9003a 100644
--- a/automated_ingestion/iac/variables.tf
+++ b/automated_ingestion/iac/variables.tf
@@ -11,8 +11,8 @@ variable "region" {
variable "bucket_location" {
type = string
- description = "Bucket location. US-CENTRAL1 keeps the raw zone in the same region as Cloud SQL, so replay reads do not cross regions."
- default = "US-CENTRAL1"
+ description = "Bucket location. Must match the Cloud SQL region so replay reads do not cross regions and pay egress. The dataservices instance is in us-west4."
+ default = "US-WEST4"
}
variable "cloud_sql_instance" {
From 33b541c866331092a29cf0e3af3bef30d3ce05b3 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 20:57:05 -0700
Subject: [PATCH 086/151] fix(ingestion): make the role grants runnable and
drop the CREATE ROLE
Two things were wrong with this script.
It told the operator to create the role. Registering the service account as a
Cloud SQL IAM user already creates the Postgres role, which Terraform does via
google_sql_user -- `gcloud sql users list` shows
ocotillo-ingestion@waterdatainitiative-271000.iam as CLOUD_IAM_SERVICE_ACCOUNT
on the instance. A CREATE ROLE would fail, and wanting one is a sign the
Terraform half has not been applied.
It also hardcoded the role name via \set while referencing an unset db_name, so
it could not run as written. Both are now required parameters and the script
stops with a readable message if either is missing. That matters more than
convenience here: the instance hosts both `ocotillo` and `ocotillo-staging`,
and granting against the wrong one would succeed silently.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/sql/ingestion_role.sql | 51 ++++++++++++++++------
1 file changed, 37 insertions(+), 14 deletions(-)
diff --git a/automated_ingestion/sql/ingestion_role.sql b/automated_ingestion/sql/ingestion_role.sql
index 990b9a5e1..25350ca9d 100644
--- a/automated_ingestion/sql/ingestion_role.sql
+++ b/automated_ingestion/sql/ingestion_role.sql
@@ -13,25 +13,48 @@
-- IAM authentication is the configured path, and the reason is that it removes
-- the credential rather than rotating it: Cloud SQL mints a short-lived token
--- from the service account, so there is no password to store in Dagster+, in
--- Secret Manager, or here.
+-- from the service account, so there is no password to store anywhere.
--
--- The role name is the service account with the .gserviceaccount.com suffix
--- stripped. That exact string is also what CLOUD_SQL_USER must be set to --
--- db/engine.py passes it straight to the connector, and a plain role name there
--- fails as an authentication error that reads like a missing grant.
+-- **The role already exists.** Registering the service account as a Cloud SQL
+-- IAM user creates the Postgres role automatically -- Terraform does that via
+-- google_sql_user.ingestion. Confirmed with:
--
--- CREATE ROLE "ocotillo-ingestion@waterdatainitiative-271000.iam" WITH LOGIN;
--- GRANT cloudsqliamuser TO "ocotillo-ingestion@waterdatainitiative-271000.iam";
+-- gcloud sql users list --instance=dataservices
+-- ...
+-- ocotillo-ingestion@waterdatainitiative-271000.iam CLOUD_IAM_SERVICE_ACCOUNT
--
--- Password authentication, if IAM is ever unavailable. Set the password out of
--- band and store it in Secret Manager; never commit it, and set
--- CLOUD_SQL_IAM_AUTH=0 so the two settings agree.
+-- So this script only grants. Do not add a CREATE ROLE: it would fail, and
+-- reaching for one is a sign the Terraform half has not been applied.
--
--- CREATE ROLE ocotillo_ingestion LOGIN PASSWORD '...';
+-- Run it as a superuser, passing both names -- nothing is hardcoded, because
+-- the instance hosts `ocotillo` and `ocotillo-staging` and running the wrong
+-- one is silent:
+--
+-- psql "host=... dbname=ocotillo user=postgres" \
+-- -v db_name=ocotillo \
+-- -v role_name=ocotillo-ingestion@waterdatainitiative-271000.iam \
+-- -f automated_ingestion/sql/ingestion_role.sql
+--
+-- The role name has an @ and dots, so every reference below uses :"role_name",
+-- which quotes it as an identifier. An unquoted one is a syntax error.
+--
+-- Password authentication, if IAM is ever unavailable: create the role by hand,
+-- store the password in Secret Manager, set CLOUD_SQL_IAM_AUTH=0, and pass
+-- -v role_name=ocotillo_ingestion instead.
+
+\if :{?db_name}
+\else
+\echo 'ERROR: pass -v db_name=. The instance hosts more than one.'
+\quit
+\endif
+
+\if :{?role_name}
+\else
+\echo 'ERROR: pass -v role_name=. See the header for the IAM role name.'
+\quit
+\endif
--- Set to match whichever role was created above.
-\set role_name "ocotillo-ingestion@waterdatainitiative-271000.iam"
+\echo 'Granting to' :"role_name" 'on' :"db_name"
GRANT CONNECT ON DATABASE :"db_name" TO :"role_name";
GRANT USAGE ON SCHEMA public TO :"role_name";
From fbd715dc51d24a8b27dfc7c8d1822dbf6731eac6 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 21:08:36 -0700
Subject: [PATCH 087/151] chore(ingestion): stop tracking the Terraform state
lock file
.terraform.tfstate.lock.info was committed with the us-west4 change. It is
written while a plan or apply holds the lock and left behind when a run is
interrupted -- which is how it got picked up. It is machine-specific, and a
stale one in a fresh checkout is actively misleading, since Terraform reports it
as another user holding the lock.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/iac/.gitignore | 3 +++
automated_ingestion/iac/.terraform.tfstate.lock.info | 1 -
2 files changed, 3 insertions(+), 1 deletion(-)
delete mode 100644 automated_ingestion/iac/.terraform.tfstate.lock.info
diff --git a/automated_ingestion/iac/.gitignore b/automated_ingestion/iac/.gitignore
index 72869f3b0..d55808b94 100644
--- a/automated_ingestion/iac/.gitignore
+++ b/automated_ingestion/iac/.gitignore
@@ -1,5 +1,8 @@
.terraform/
.terraform.lock.hcl
+# Written while a plan or apply holds the state lock, and left behind if the
+# run is interrupted. Machine-specific and never useful to another checkout.
+.terraform.tfstate.lock.info
terraform.tfstate
terraform.tfstate.*
terraform.tfvars
diff --git a/automated_ingestion/iac/.terraform.tfstate.lock.info b/automated_ingestion/iac/.terraform.tfstate.lock.info
deleted file mode 100644
index b3e86bd78..000000000
--- a/automated_ingestion/iac/.terraform.tfstate.lock.info
+++ /dev/null
@@ -1 +0,0 @@
-{"ID":"dbce0c93-cc58-cb45-fa55-faf792ad5025","Operation":"OperationTypeApply","Info":"","Who":"jakeross@Jakes-MacBook-Pro.local","Version":"1.14.8","Created":"2026-08-19T03:37:52.057173Z","Path":"terraform.tfstate"}
\ No newline at end of file
From c3900738408544a8614f347e11dde3bdf24aba1b Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 21:21:31 -0700
Subject: [PATCH 088/151] fix(ingestion): grant bucket read, and name the
pipeline after the bucket
raw_san_acacia_locations failed with "Bucket does not exist:
ocotillo-ingestion-production" against a bucket that exists. objectAdmin covers
objects and says nothing about the bucket, so it omits storage.buckets.get.
gcsfs checks a bucket exists before writing, that check was denied, and GCS
reports denial as absence -- the same 404-for-403 shape as the Secret Manager
failure earlier. legacyBucketReader adds buckets.get and objects.list and
nothing else; storage.admin would also permit deleting the bucket.
The same traceback showed a pipeline named san_acacia_staging writing to the
production bucket. The name came from a run tag that was absent, defaulting to
staging, while the bucket came from the environment: two sources of truth for
one fact, free to disagree. The name is now derived from the bucket, so they
cannot.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/iac/main.tf | 18 ++++++++++++++++++
.../sources/san_acacia/dlt_pipeline.py | 17 +++++++++++++----
.../sources/san_acacia/ingest.py | 4 ++--
automated_ingestion/tests/test_gcs.py | 15 +++++++++++++++
4 files changed, 48 insertions(+), 6 deletions(-)
diff --git a/automated_ingestion/iac/main.tf b/automated_ingestion/iac/main.tf
index 47bd0dac3..295cf3a35 100644
--- a/automated_ingestion/iac/main.tf
+++ b/automated_ingestion/iac/main.tf
@@ -107,6 +107,24 @@ resource "google_storage_bucket_iam_member" "ingestion_object_admin" {
member = "serviceAccount:${google_service_account.ingestion.email}"
}
+
+# objectAdmin covers objects and says nothing about the bucket itself, so it
+# does not include storage.buckets.get. gcsfs checks a bucket exists before
+# writing to it, that check is denied, and GCS reports a denial as absence --
+# so the pipeline fails with "Bucket does not exist" for a bucket that plainly
+# does.
+#
+# legacyBucketReader adds buckets.get and objects.list and nothing else. It is
+# the narrowest standard role that makes the existence check succeed; the
+# alternative, storage.admin, would also grant deletion of the bucket.
+resource "google_storage_bucket_iam_member" "ingestion_bucket_reader" {
+ for_each = google_storage_bucket.ingestion_raw
+
+ bucket = each.value.name
+ role = "roles/storage.legacyBucketReader"
+ member = "serviceAccount:${google_service_account.ingestion.email}"
+}
+
# Database access for the ingestion service account.
#
# Only created when `cloud_sql_instance` is set, so the storage half of this
diff --git a/automated_ingestion/sources/san_acacia/dlt_pipeline.py b/automated_ingestion/sources/san_acacia/dlt_pipeline.py
index 25b08a270..4c64088f4 100644
--- a/automated_ingestion/sources/san_acacia/dlt_pipeline.py
+++ b/automated_ingestion/sources/san_acacia/dlt_pipeline.py
@@ -171,8 +171,16 @@ def _approved_timestamps(
return set()
-def build_pipeline(environment: str) -> Any:
- """A dlt pipeline writing parquet to the raw zone for one environment."""
+def build_pipeline() -> Any:
+ """A dlt pipeline writing parquet to the raw zone.
+
+ The pipeline is named after the bucket it writes to rather than after a
+ separately supplied environment. Those were two sources of truth for one
+ fact, and they disagreed the first time this ran in Dagster+: a pipeline
+ called ``san_acacia_staging`` writing to the production bucket, because the
+ name came from a run tag that was absent and the bucket came from the
+ environment. Deriving one from the other makes that impossible.
+ """
# gcsfs resolves Application Default Credentials the same way the Cloud SQL
# connector does, and Serverless supplies none of its own.
from automated_ingestion.shared.credentials import (
@@ -181,10 +189,11 @@ def build_pipeline(environment: str) -> Any:
ensure_application_default_credentials()
+ bucket = raw_zone_bucket()
return dlt.pipeline(
- pipeline_name=f"san_acacia_{environment}",
+ pipeline_name=f"{SOURCE.key}_{bucket}",
destination=dlt.destinations.filesystem(
- bucket_url=f"gs://{raw_zone_bucket()}",
+ bucket_url=f"gs://{bucket}",
layout=RAW_LAYOUT,
),
dataset_name=SOURCE.dataset_name,
diff --git a/automated_ingestion/sources/san_acacia/ingest.py b/automated_ingestion/sources/san_acacia/ingest.py
index 6486f2a2d..4d7d8a021 100644
--- a/automated_ingestion/sources/san_acacia/ingest.py
+++ b/automated_ingestion/sources/san_acacia/ingest.py
@@ -50,7 +50,7 @@ def raw_san_acacia_locations(context: AssetExecutionContext) -> Output[int]:
client = _client()
points = list(client.monitoring_points(PROJECT_ID))
- pipeline = build_pipeline(context.run.tags.get("environment", "staging"))
+ pipeline = build_pipeline()
pipeline.run(vanessen_locations(client))
context.log.info("landed %s monitoring points", len(points))
@@ -84,7 +84,7 @@ def raw_san_acacia_readings(context: AssetExecutionContext) -> Output[int]:
]
end = int(datetime.now(tz=timezone.utc).timestamp())
- pipeline = build_pipeline(context.run.tags.get("environment", "staging"))
+ pipeline = build_pipeline()
failures: list[dict[str, Any]] = []
info = pipeline.run(vanessen_readings(client, points, end, failures))
rows = _row_count(info)
diff --git a/automated_ingestion/tests/test_gcs.py b/automated_ingestion/tests/test_gcs.py
index b9875a1d5..ad219715f 100644
--- a/automated_ingestion/tests/test_gcs.py
+++ b/automated_ingestion/tests/test_gcs.py
@@ -59,4 +59,19 @@ def test_layout_partitions_by_date():
assert "day={DD}" in RAW_LAYOUT
+def test_pipeline_name_follows_the_bucket(monkeypatch):
+ # The name and the destination must not be able to disagree. They did once:
+ # a pipeline called san_acacia_staging wrote to the production bucket,
+ # because the name came from an absent run tag and the bucket from the
+ # environment.
+ monkeypatch.setenv(BUCKET_ENV_VAR, "ocotillo-ingestion-production")
+ monkeypatch.delenv("GCS_BUCKET_NAME", raising=False)
+ monkeypatch.setenv("INGESTION_GCP_CREDENTIALS_JSON", "")
+
+ from automated_ingestion.sources.san_acacia.dlt_pipeline import build_pipeline
+
+ pipeline = build_pipeline()
+ assert "ocotillo-ingestion-production" in pipeline.pipeline_name
+
+
# ============= EOF =============================================
From 91f450436bab030a9e834271169c68c6bfd25cae Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 21:46:30 -0700
Subject: [PATCH 089/151] feat(ingestion): add the Van Essen domain rules and
adapter
Turns diver readings into Ocotillo values: timestamps to timezone-aware UTC,
centimetres to feet, and deterministic external keys. Pure functions, no
database and no network, per ADR4 -- asserted by a test that checks the import
graph rather than by reading the file.
Narrower than the plan described. The draft had this converting drillingDepth
and building a point from lat/lng, but the live MonitoringPoint payload is
{id, name}: no depth, no coordinates. Those functions would have had no input,
so the plan is corrected rather than the functions written.
The adapter refuses a row whose reference is not ground surface, and one whose
unit is not centimetres. Both would otherwise produce plausible numbers instead
of an error -- the datum is chosen at request time and is not recoverable from
the row, and an unconverted centimetre value reads as a believable depth while
being wrong by a factor of 30.48.
External keys use the vendor's numeric id, not the name. SO-0125 is a Bureau
point id and can be corrected; the numeric id is what the vendor's URLs use and
what a re-run has to resolve to the same record.
Negative depths are kept: water stands above ground in these riparian wells at
high flow, and clamping would erase real data.
Co-Authored-By: Claude Opus 5
---
.../sources/san_acacia/adapter.py | 88 ++++++++++-
.../tests/test_san_acacia_adapter.py | 71 +++++++++
docs/automated-ingestion-pipeline-plan.md | 26 +++-
domain/van_essen.py | 146 ++++++++++++++++++
tests/test_van_essen_domain.py | 102 ++++++++++++
5 files changed, 424 insertions(+), 9 deletions(-)
create mode 100644 automated_ingestion/tests/test_san_acacia_adapter.py
create mode 100644 domain/van_essen.py
create mode 100644 tests/test_van_essen_domain.py
diff --git a/automated_ingestion/sources/san_acacia/adapter.py b/automated_ingestion/sources/san_acacia/adapter.py
index 0d5d89731..d02bedecf 100644
--- a/automated_ingestion/sources/san_acacia/adapter.py
+++ b/automated_ingestion/sources/san_acacia/adapter.py
@@ -13,6 +13,92 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
-"""Van Essen records to Ocotillo structures. Implemented under BDMS task 3.1."""
+"""
+Van Essen mapping rules for the San Acacia source.
+
+The adapter is the only place the vendor's vocabulary meets Ocotillo's. It is
+pure enough to test without a database: it takes raw records and returns
+structures, and the loader turns those into rows.
+
+Per-record failure isolation matches the rest of the pipeline. One unparseable
+reading costs that reading, not the series -- a diver that logs one bad row
+should not lose a month of good ones.
+"""
+
+from collections.abc import Iterable, Iterator
+from typing import Any
+
+from domain.van_essen import (
+ GROUND_SURFACE_REFERENCE,
+ MEASUREMENT_UNIT,
+ VanEssenMappingError,
+ depth_to_water_ft,
+ external_point_key,
+ parse_reading_timestamp,
+)
+
+from automated_ingestion.ocotillo.adapter import SourceAdapter
+from automated_ingestion.ocotillo.structs import ObservationRecord
+
+
+class SanAcaciaAdapter(SourceAdapter):
+ """Maps Diver-HUB water levels onto Ocotillo observations."""
+
+ def __init__(self) -> None:
+ self.failures: list[dict[str, Any]] = []
+
+ @property
+ def source_key(self) -> str:
+ return "san_acacia"
+
+ def to_observations(
+ self, records: Iterable[dict[str, Any]]
+ ) -> Iterator[ObservationRecord]:
+ """Convert raw rows, collecting per-record failures rather than raising.
+
+ Rows whose ``reference`` is not ground surface are refused outright. The
+ datum is chosen at request time and cannot be recovered from the row, so
+ accepting one would mean storing a number whose meaning is unknown --
+ the single failure this pipeline must not produce quietly.
+ """
+ for record in records:
+ try:
+ yield self._to_observation(record)
+ except VanEssenMappingError as exc:
+ self.failures.append({"record": _identify(record), "error": str(exc)})
+
+ def _to_observation(self, record: dict[str, Any]) -> ObservationRecord:
+ reference = record.get("reference")
+ if reference != GROUND_SURFACE_REFERENCE:
+ raise VanEssenMappingError(
+ f"Reading was fetched with reference={reference!r}, not "
+ f"{GROUND_SURFACE_REFERENCE} (ground surface). Its datum is not "
+ "recoverable from the row."
+ )
+
+ unit = record.get("unit")
+ if unit != "cm":
+ raise VanEssenMappingError(
+ f"Reading unit is {unit!r}, expected 'cm'. Converting a value "
+ "whose unit is not what it claims would be wrong by a factor."
+ )
+
+ point_id = record.get("monitoring_point_id")
+ value = depth_to_water_ft(record.get("level"))
+ if value is None:
+ raise VanEssenMappingError("Reading has no level; nothing to store.")
+
+ return ObservationRecord(
+ external_point_id=external_point_key(point_id),
+ observation_datetime=parse_reading_timestamp(record.get("dateAndTime")),
+ value=value,
+ units=MEASUREMENT_UNIT,
+ )
+
+
+def _identify(record: dict[str, Any]) -> str:
+ """A short handle for a failed record, for logs and metadata."""
+ return f"{record.get('monitoring_point_id')}@{record.get('dateAndTime')}"
+
# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_san_acacia_adapter.py b/automated_ingestion/tests/test_san_acacia_adapter.py
new file mode 100644
index 000000000..a4e074a02
--- /dev/null
+++ b/automated_ingestion/tests/test_san_acacia_adapter.py
@@ -0,0 +1,71 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Adapter behaviour: what it refuses, and that one bad row costs only that row.
+"""
+
+from automated_ingestion.sources.san_acacia.adapter import SanAcaciaAdapter
+
+
+def _row(**overrides):
+ row = {
+ "monitoring_point_id": 40,
+ "dateAndTime": "2024-10-30T20:00:00",
+ "level": 471.518,
+ "unit": "cm",
+ "reference": 3,
+ }
+ row.update(overrides)
+ return row
+
+
+def test_maps_a_good_row():
+ [observation] = list(SanAcaciaAdapter().to_observations([_row()]))
+ assert observation.external_point_id == "sanacaciareach-40"
+ assert observation.value == 15.469751
+ assert observation.units == "ft"
+
+
+def test_wrong_datum_is_refused():
+ # The datum is chosen at request time and cannot be recovered from the row,
+ # so a reading fetched against another reference has unknown meaning.
+ adapter = SanAcaciaAdapter()
+ assert list(adapter.to_observations([_row(reference=1)])) == []
+ assert "not 3" in adapter.failures[0]["error"]
+
+
+def test_unexpected_unit_is_refused():
+ # Converting a value whose unit is not what it claims is wrong by a factor
+ # of 30.48 and still looks like a plausible depth.
+ adapter = SanAcaciaAdapter()
+ assert list(adapter.to_observations([_row(unit="ft")])) == []
+ assert "expected 'cm'" in adapter.failures[0]["error"]
+
+
+def test_one_bad_row_does_not_lose_the_others():
+ adapter = SanAcaciaAdapter()
+ rows = [_row(), _row(dateAndTime="broken"), _row(dateAndTime="2024-10-30T21:00:00")]
+ assert len(list(adapter.to_observations(rows))) == 2
+ assert len(adapter.failures) == 1
+
+
+def test_failures_identify_the_record():
+ adapter = SanAcaciaAdapter()
+ list(adapter.to_observations([_row(level=None)]))
+ assert adapter.failures[0]["record"] == "40@2024-10-30T20:00:00"
+
+
+# ============= EOF =============================================
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index 7a3773081..190625b10 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -243,16 +243,26 @@ Where this stops resembling Aqueduct: the destination is a relational database w
### 3.1 — Domain layer: Van Essen record → Ocotillo model
-Per `ADR4.md`, `domain/` imports nothing from `api/`, `db/`, `schemas/`, `services/`, and no fastapi/sqlalchemy/pydantic/httpx.
+Built. `domain/van_essen.py` plus `sources/san_acacia/adapter.py`, 28 tests, no database and no network.
-`domain/van_essen.py`, pure functions:
-- `drillingDepth` cm → ft (÷ 30.48), reusing `domain/units.py` where it fits
-- reading timestamp → tz-aware UTC `datetime`
-- `gs` reading → DTW below ground surface, feet (datum fixed — see Epic)
-- `lat`/`lng` → WGS84 point (SRID 4326)
-- deterministic external key per well and per series, so repeat runs resolve to the same records
+**Scope is smaller than this section originally claimed.** The draft called for converting `drillingDepth` from centimetres and building a WGS84 point from `lat`/`lng`. The live `MonitoringPoint` payload is `{id, name}` — no depth, no coordinates — so those functions would have had no input. Well geometry and construction come from the Ocotillo records a point reconciles against, which is consistent with ingestion never creating wells.
-Plus an adapter in Aqueduct's `BaseAdapter` shape, with the same per-record failure isolation: a bad record is logged and counted, never fatal. Domain errors subclass `ValueError`, matching the CSV importers' per-row contract. Tests need no database and no network. Every value the mapping *invents* rather than reads is listed in the module docstring with its justification.
+What the layer actually does:
+
+- ✅ Reading timestamp → timezone-aware UTC. A naive value is read as UTC, since the API documents UTC and does not always mark it; reading it as local would shift every observation by the machine's offset, and differently on a laptop than in a container.
+- ✅ Centimetres → feet via `domain/units.convert_cm_to_ft`.
+- ✅ Deterministic external keys, built from the vendor's **numeric** id rather than the name. `SO-0125` is a Bureau point id and can be corrected; the numeric id is what a re-run must resolve to the same record. The series key names the datum, because a point may later carry temperature or conductivity — both already in the vendor's raw payload.
+- ✅ Errors subclass `ValueError`, matching the per-row contract the CSV importers expect.
+- ✅ ADR4 layering verified by test rather than by inspection: importing `domain.van_essen` pulls in no `fastapi`, `sqlalchemy`, `pydantic`, `httpx`, `db`, `api`, `schemas`, or `services`.
+
+**The adapter refuses two things outright**, both because accepting them would produce plausible numbers rather than an error:
+
+- A row whose `reference` is not 3. The datum is chosen at request time and cannot be recovered from the row.
+- A row whose `unit` is not `cm`. Converting a value whose unit is not what it claims is wrong by a factor of 30.48 and still reads as a plausible depth.
+
+Per-record failures are collected, not raised: one unparseable reading costs that reading, not the series.
+
+The module docstring lists every value the mapping **invents** rather than reads — the datum, the unit, and the timezone — since inventing is where a mapping goes quietly wrong.
### 3.2 — Bootstrap reference data: reconcile wells, seed parameter, sensor, deployments
diff --git a/domain/van_essen.py b/domain/van_essen.py
new file mode 100644
index 000000000..9278318b2
--- /dev/null
+++ b/domain/van_essen.py
@@ -0,0 +1,146 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Rules for turning Van Essen diver readings into Ocotillo values.
+
+The vendor's Diver-HUB API reports water levels in centimetres against a datum
+chosen by the request, and Ocotillo stores feet below ground surface. These
+functions do that conversion and nothing else: no database, no HTTP, no vendor
+client. The caller fetches; these rules decide what a fetched value means.
+
+**What this mapping invents rather than reads**, since inventing is where a
+mapping goes quietly wrong:
+
+- *The datum.* The API does not say which datum a reading is on -- the request
+ does. Every value here is assumed to have come from a request with
+ ``reference=3``, established by measurement (see
+ ``docs/sources/san_acacia.md``). A reading fetched with any other reference and
+ passed through here is silently wrong, which is why the client has no default
+ for that parameter.
+- *The unit.* No field states it. Centimetres was inferred from the elevation
+ reference resolving to San Acacia's ground elevation, and cross-checked
+ against plausible depths for a riparian piezometer.
+- *The timezone.* The API documents UTC but does not always mark it, so a naive
+ timestamp is read as UTC rather than as local time.
+
+**What it deliberately does not do.** Earlier drafts had this module converting
+``drillingDepth`` from centimetres and building a WGS84 point from ``lat``/
+``lng``. The live ``MonitoringPoint`` payload is ``{id, name}`` -- no depth, no
+coordinates -- so those functions would have had no input. Well geometry and
+construction come from the Ocotillo records a point reconciles against.
+
+Errors subclass ``ValueError`` so a bad record is a per-row failure to the
+caller, matching what the CSV importers already expect.
+"""
+
+import math
+from datetime import datetime, timezone
+
+from domain.units import convert_cm_to_ft
+
+PROJECT_SLUG = "sanacaciareach"
+"""Prefix for external identifiers. Matches the vendor's own ``uid`` form."""
+
+MEASUREMENT_UNIT = "ft"
+"""Ocotillo stores depth to water in feet."""
+
+GROUND_SURFACE_REFERENCE = 3
+"""The ``WaterLevelReference`` these rules assume a reading was fetched with.
+
+Duplicated from the client deliberately: a rule that assumes a datum should
+state which one, so that reading this module alone is enough to know what its
+numbers mean.
+"""
+
+
+class VanEssenMappingError(ValueError):
+ """A record cannot be mapped. Per-row, never fatal to a run."""
+
+
+def parse_reading_timestamp(value: str) -> datetime:
+ """Parse a Diver-HUB ``dateAndTime`` into a timezone-aware UTC datetime.
+
+ A naive timestamp is read as UTC. Reading it as local time would shift every
+ observation by the machine's offset -- and would do so differently on a
+ developer's laptop and in a container, which is the kind of discrepancy that
+ survives review.
+ """
+ if not isinstance(value, str) or not value.strip():
+ raise VanEssenMappingError(f"Reading timestamp is missing or blank: {value!r}")
+
+ text = value.strip().replace("Z", "+00:00")
+ try:
+ parsed = datetime.fromisoformat(text)
+ except ValueError as exc:
+ raise VanEssenMappingError(
+ f"Reading timestamp {value!r} is not an ISO-8601 instant."
+ ) from exc
+
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.astimezone(timezone.utc)
+
+
+def depth_to_water_ft(level_cm: float | None) -> float | None:
+ """Convert a ground-surface reading in centimetres to feet.
+
+ ``None`` passes through: the vendor reports gaps, and a gap is not an error.
+
+ Negative values are kept. Depth below ground surface goes negative when
+ water stands above ground, which happens in these riparian wells during high
+ flow and is real data rather than a fault.
+ """
+ if level_cm is None:
+ return None
+ if isinstance(level_cm, bool) or not isinstance(level_cm, (int, float)):
+ raise VanEssenMappingError(f"Reading level is not a number: {level_cm!r}")
+ if math.isnan(level_cm) or math.isinf(level_cm):
+ raise VanEssenMappingError(f"Reading level is not finite: {level_cm!r}")
+
+ return convert_cm_to_ft(float(level_cm))
+
+
+def external_point_key(monitoring_point_id: int) -> str:
+ """Stable identifier for a monitoring point.
+
+ Built from the vendor's numeric id rather than its name. Names like
+ ``SO-0125`` are Bureau point ids and can be corrected; the numeric id is what
+ the vendor's URLs use and is what a re-run has to resolve to the same record.
+ """
+ if isinstance(monitoring_point_id, bool) or not isinstance(
+ monitoring_point_id, int
+ ):
+ raise VanEssenMappingError(
+ f"Monitoring point id must be an integer: {monitoring_point_id!r}"
+ )
+ if monitoring_point_id <= 0:
+ raise VanEssenMappingError(
+ f"Monitoring point id must be positive: {monitoring_point_id!r}"
+ )
+ return f"{PROJECT_SLUG}-{monitoring_point_id}"
+
+
+def external_series_key(monitoring_point_id: int) -> str:
+ """Stable identifier for one point's depth-to-water series.
+
+ A point could later carry more than one series -- temperature and
+ conductivity are already in the vendor's raw payload -- so the datum is part
+ of the key rather than implied by the point.
+ """
+ return f"{external_point_key(monitoring_point_id)}:dtw-gs"
+
+
+# ============= EOF =============================================
diff --git a/tests/test_van_essen_domain.py b/tests/test_van_essen_domain.py
new file mode 100644
index 000000000..a7c2003bd
--- /dev/null
+++ b/tests/test_van_essen_domain.py
@@ -0,0 +1,102 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Van Essen mapping rules.
+
+No database, no network -- these are the rules alone, which is the point of
+keeping them in `domain/`.
+"""
+
+from datetime import datetime, timezone
+
+import pytest
+
+from domain.van_essen import (
+ VanEssenMappingError,
+ depth_to_water_ft,
+ external_point_key,
+ external_series_key,
+ parse_reading_timestamp,
+)
+
+
+class TestTimestamps:
+ def test_naive_is_read_as_utc(self):
+ # The API documents UTC and does not always mark it. Reading naive as
+ # local would shift every observation by the machine's offset, and shift
+ # it differently on a laptop and in a container.
+ assert parse_reading_timestamp("2024-10-30T20:00:00") == datetime(
+ 2024, 10, 30, 20, 0, tzinfo=timezone.utc
+ )
+
+ def test_explicit_utc_matches_naive(self):
+ assert parse_reading_timestamp(
+ "2024-10-30T20:00:00Z"
+ ) == parse_reading_timestamp("2024-10-30T20:00:00")
+
+ def test_offset_is_normalized_to_utc(self):
+ assert parse_reading_timestamp("2024-10-30T14:00:00-06:00") == datetime(
+ 2024, 10, 30, 20, 0, tzinfo=timezone.utc
+ )
+
+ @pytest.mark.parametrize("value", ["", " ", None, "not-a-date", "2024-13-45"])
+ def test_unusable_timestamps_raise(self, value):
+ with pytest.raises(VanEssenMappingError):
+ parse_reading_timestamp(value)
+
+
+class TestDepthConversion:
+ def test_centimetres_become_feet(self):
+ # SO-0125 on 2024-10-30: 471.518 cm below ground surface.
+ assert depth_to_water_ft(471.518) == 15.469751
+
+ def test_gap_passes_through(self):
+ # The vendor reports gaps. A gap is not an error.
+ assert depth_to_water_ft(None) is None
+
+ def test_negative_depth_is_kept(self):
+ # Depth below ground goes negative when water stands above ground, which
+ # happens in these riparian wells at high flow. Clamping would erase
+ # real data.
+ assert depth_to_water_ft(-50.0) == pytest.approx(-1.64042, rel=1e-4)
+
+ @pytest.mark.parametrize("value", [float("nan"), float("inf"), "471.518", True])
+ def test_unusable_values_raise(self, value):
+ with pytest.raises(VanEssenMappingError):
+ depth_to_water_ft(value)
+
+
+class TestExternalKeys:
+ def test_point_key_uses_the_numeric_id(self):
+ # Names like SO-0125 are Bureau point ids and can be corrected; the
+ # numeric id is what a re-run must resolve to the same record.
+ assert external_point_key(40) == "sanacaciareach-40"
+
+ def test_series_key_names_the_datum(self):
+ # A point may later carry temperature or conductivity, both already in
+ # the vendor's raw payload.
+ assert external_series_key(40) == "sanacaciareach-40:dtw-gs"
+
+ def test_keys_are_stable_across_calls(self):
+ assert external_point_key(40) == external_point_key(40)
+
+ @pytest.mark.parametrize("value", [0, -1, "40", None, True])
+ def test_unusable_ids_raise(self, value):
+ with pytest.raises(VanEssenMappingError):
+ external_point_key(value)
+
+
+# ============= EOF =============================================
From 31e06448988f0edff6a6c19b98caa61c4a385645 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 21:54:14 -0700
Subject: [PATCH 090/151] feat(ingestion): add the transducer unique constraint
and upsert loader
The table had only an index on (deployment_id, parameter_id,
observation_datetime), so nothing prevented inserting the same reading twice.
That absence is what forces a delete-then-repost load: with no constraint to
conflict on, a re-run can only avoid duplicates by removing what is there
first, leaving a window where the data is missing and, if the run fails
midway, leaving it missing for good.
The constraint is on deployment_id, not thing_id as the plan said -- that
column is on the block table, not this one. The existing index already used
deployment_id, and the scope is right anyway: a deployment is a thing/sensor
pairing, so two sensors on one well may legitimately report the same instant.
The migration drops the old index, since the constraint creates an equivalent
one and keeping both means maintaining two on every insert into the largest
table in the schema. Verified up and down against a database with 88,666 rows.
find_duplicate_observations.sql runs first: the migration fails on a table that
already violates the constraint. It separates redundant copies from groups
whose values disagree, because those are conflicting measurements rather than
duplicates and collapsing them would discard a reading somebody recorded.
The loader upserts with DO UPDATE rather than DO NOTHING, so a vendor
correction is applied instead of silently ignored, and uses Core rather than
ORM objects per AGENTS.md. The idempotency test runs against a real Postgres,
because the claim depends on the database enforcing the constraint.
Co-Authored-By: Claude Opus 5
---
...2c3d4e5f6_unique_transducer_observation.py | 48 +++++
automated_ingestion/ocotillo/loader.py | 180 ++++++++++++++++++
.../sql/find_duplicate_observations.sql | 44 +++++
db/transducer.py | 12 +-
docs/automated-ingestion-pipeline-plan.md | 17 +-
tests/test_transducer_loader.py | 117 ++++++++++++
6 files changed, 410 insertions(+), 8 deletions(-)
create mode 100644 alembic/versions/a1b2c3d4e5f6_unique_transducer_observation.py
create mode 100644 automated_ingestion/ocotillo/loader.py
create mode 100644 automated_ingestion/sql/find_duplicate_observations.sql
create mode 100644 tests/test_transducer_loader.py
diff --git a/alembic/versions/a1b2c3d4e5f6_unique_transducer_observation.py b/alembic/versions/a1b2c3d4e5f6_unique_transducer_observation.py
new file mode 100644
index 000000000..8b70eb7bc
--- /dev/null
+++ b/alembic/versions/a1b2c3d4e5f6_unique_transducer_observation.py
@@ -0,0 +1,48 @@
+"""unique constraint on transducer_observation
+
+Revision ID: a1b2c3d4e5f6
+Revises: d9e0f1a2b3c4
+Create Date: 2026-08-19
+
+The table had only an index on (deployment_id, parameter_id,
+observation_datetime), so nothing prevented the same reading being inserted
+twice. That absence is what forces a delete-then-repost load strategy: without a
+constraint to conflict on, a re-run can only avoid duplicates by removing what
+is already there first, which leaves a window where the data is missing.
+
+With this constraint the loader can use ON CONFLICT DO UPDATE and a re-run
+becomes idempotent, so a backfill overlapping existing data is safe.
+
+Note the constraint is on `deployment_id`, not `thing_id` -- the plan named a
+column this table does not have. A deployment is a thing/sensor pairing, so two
+sensors on the same well may legitimately report the same instant; scoping
+uniqueness to the deployment allows that while still catching a re-inserted row.
+
+**Run automated_ingestion/sql/find_duplicate_observations.sql first.** This
+migration fails on a table that already violates the constraint, and it is
+better to know that before starting than halfway through.
+"""
+
+from alembic import op
+
+revision = "a1b2c3d4e5f6"
+down_revision = "d9e0f1a2b3c4"
+branch_labels = None
+depends_on = None
+
+CONSTRAINT_NAME = "uq_transducer_observation_deployment_parameter_datetime"
+INDEX_NAME = "ix_transducer_observation_deployment_parameter_datetime"
+COLUMNS = ["deployment_id", "parameter_id", "observation_datetime"]
+
+
+def upgrade() -> None:
+ # The unique constraint creates its own index on the same columns, so the
+ # existing one would be redundant -- two indexes maintained on every insert
+ # into the largest table in the schema.
+ op.drop_index(INDEX_NAME, table_name="transducer_observation")
+ op.create_unique_constraint(CONSTRAINT_NAME, "transducer_observation", COLUMNS)
+
+
+def downgrade() -> None:
+ op.drop_constraint(CONSTRAINT_NAME, "transducer_observation", type_="unique")
+ op.create_index(INDEX_NAME, "transducer_observation", COLUMNS)
diff --git a/automated_ingestion/ocotillo/loader.py b/automated_ingestion/ocotillo/loader.py
new file mode 100644
index 000000000..a4eb0d92e
--- /dev/null
+++ b/automated_ingestion/ocotillo/loader.py
@@ -0,0 +1,180 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Idempotent loading of observations into Ocotillo.
+
+Every write is an upsert against the unique constraint on
+``(deployment_id, parameter_id, observation_datetime)``. That makes a re-run a
+no-op rather than a duplication, which is what lets a backfill overlap existing
+data safely.
+
+The alternative -- delete the window, then insert -- is what Aqueduct does
+against FROST, because there is no constraint there to conflict on. It leaves a
+window during which the data is simply missing, and a failure mid-way leaves it
+missing permanently. Upserting has no such window.
+
+Rows are written with SQLAlchemy Core rather than ORM objects. ``AGENTS.md``
+is explicit about this for high-volume tables: instantiating a mapped class per
+observation is what turns a backfill into an hour-long run.
+"""
+
+from collections.abc import Iterable, Iterator
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any
+
+DEFAULT_BATCH_SIZE = 5_000
+"""Rows per statement.
+
+Large enough that a month of five-minute readings is a handful of round trips,
+small enough that one batch's parameters do not approach Postgres' limit. Each
+batch commits on its own, so an interrupted load keeps what it had already
+written -- with an upsert, resuming simply rewrites those rows.
+"""
+
+
+@dataclass
+class LoadResult:
+ """What a load did, for reporting as asset metadata."""
+
+ rows_seen: int = 0
+ rows_written: int = 0
+ batches: int = 0
+ blocks_touched: list[int] = field(default_factory=list)
+
+ @property
+ def rows_skipped(self) -> int:
+ return self.rows_seen - self.rows_written
+
+
+def _batched(records: Iterable[Any], size: int) -> Iterator[list[Any]]:
+ batch: list[Any] = []
+ for record in records:
+ batch.append(record)
+ if len(batch) >= size:
+ yield batch
+ batch = []
+ if batch:
+ yield batch
+
+
+def load_observations(
+ session: Any,
+ records: Iterable[Any],
+ deployment_id: int,
+ parameter_id: int,
+ release_status: str,
+ batch_size: int = DEFAULT_BATCH_SIZE,
+) -> LoadResult:
+ """Upsert observations, committing per batch.
+
+ ``records`` are ``ObservationRecord`` values from an adapter; resolving a
+ source's point identifier to a deployment belongs to reference-data
+ bootstrapping, not here, so the caller supplies the ids.
+ """
+ from sqlalchemy.dialects.postgresql import insert
+
+ from db.transducer import TransducerObservation
+
+ result = LoadResult()
+ table = TransducerObservation.__table__
+
+ for batch in _batched(records, batch_size):
+ rows = [
+ {
+ "deployment_id": deployment_id,
+ "parameter_id": parameter_id,
+ "observation_datetime": record.observation_datetime,
+ "value": record.value,
+ "release_status": release_status,
+ }
+ for record in batch
+ ]
+ result.rows_seen += len(rows)
+
+ statement = insert(table).values(rows)
+ # DO UPDATE rather than DO NOTHING: a vendor may correct a reading, and
+ # a correction arriving as a no-op would leave the old value in place
+ # while the run reported success.
+ statement = statement.on_conflict_do_update(
+ index_elements=[
+ "deployment_id",
+ "parameter_id",
+ "observation_datetime",
+ ],
+ set_={"value": statement.excluded.value},
+ )
+ session.execute(statement)
+ session.commit()
+
+ result.rows_written += len(rows)
+ result.batches += 1
+
+ return result
+
+
+def ensure_block(
+ session: Any,
+ thing_id: int,
+ parameter_id: int,
+ start: datetime,
+ end: datetime,
+ release_status: str,
+ review_status: str = "not reviewed",
+) -> int:
+ """Create or widen the QC block covering a loaded window.
+
+ ``review_status`` defaults to ``not reviewed`` and callers should leave it
+ there. In Ocotillo ``approved`` asserts that a Bureau human reviewed the
+ data and carries a ``reviewer_id``; the vendor's own approval flag is a
+ different claim and is preserved separately.
+
+ An existing block is widened rather than duplicated, so re-running a window
+ does not accumulate blocks.
+ """
+ from sqlalchemy import select
+
+ from db.transducer import TransducerObservationBlock
+
+ existing = session.scalars(
+ select(TransducerObservationBlock)
+ .where(TransducerObservationBlock.thing_id == thing_id)
+ .where(TransducerObservationBlock.parameter_id == parameter_id)
+ .where(TransducerObservationBlock.review_status == review_status)
+ .where(TransducerObservationBlock.start_datetime <= end)
+ .where(TransducerObservationBlock.end_datetime >= start)
+ ).first()
+
+ if existing is not None:
+ existing.start_datetime = min(existing.start_datetime, start)
+ existing.end_datetime = max(existing.end_datetime, end)
+ session.commit()
+ return existing.id
+
+ block = TransducerObservationBlock(
+ thing_id=thing_id,
+ parameter_id=parameter_id,
+ review_status=review_status,
+ start_datetime=start,
+ end_datetime=end,
+ release_status=release_status,
+ )
+ session.add(block)
+ session.commit()
+ return block.id
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/sql/find_duplicate_observations.sql b/automated_ingestion/sql/find_duplicate_observations.sql
new file mode 100644
index 000000000..6b49727ce
--- /dev/null
+++ b/automated_ingestion/sql/find_duplicate_observations.sql
@@ -0,0 +1,44 @@
+-- Find duplicate transducer observations before adding the unique constraint.
+--
+-- The migration that adds UNIQUE (deployment_id, parameter_id,
+-- observation_datetime) will fail on a table that already violates it, and
+-- failing halfway through a production migration is worse than not starting.
+-- Run this first, on every environment the migration will touch.
+--
+-- psql "..." -f automated_ingestion/sql/find_duplicate_observations.sql
+--
+-- No rows means the migration is safe to run. Rows mean a decision is needed
+-- about which copy to keep, and that decision belongs to someone who knows the
+-- data -- deleting the higher id is a guess, not a rule, because the rows may
+-- differ in `value` rather than being true duplicates.
+
+\echo '== Duplicate groups =='
+SELECT
+ deployment_id,
+ parameter_id,
+ observation_datetime,
+ count(*) AS copies,
+ count(DISTINCT value) AS distinct_values,
+ min(id) AS lowest_id,
+ max(id) AS highest_id
+FROM transducer_observation
+GROUP BY deployment_id, parameter_id, observation_datetime
+HAVING count(*) > 1
+ORDER BY copies DESC, observation_datetime
+LIMIT 100;
+
+\echo ''
+\echo '== Totals =='
+-- `distinct_values > 1` is the interesting case: those are not redundant copies
+-- but disagreeing measurements, and collapsing them silently would discard a
+-- reading somebody recorded.
+SELECT
+ count(*) AS duplicate_groups,
+ sum(copies) - count(*) AS rows_above_the_first,
+ count(*) FILTER (WHERE distinct_values > 1) AS groups_that_disagree
+FROM (
+ SELECT count(*) AS copies, count(DISTINCT value) AS distinct_values
+ FROM transducer_observation
+ GROUP BY deployment_id, parameter_id, observation_datetime
+ HAVING count(*) > 1
+) g;
diff --git a/db/transducer.py b/db/transducer.py
index 1670bb9fa..57625e3f8 100644
--- a/db/transducer.py
+++ b/db/transducer.py
@@ -107,12 +107,20 @@ class TransducerObservation(Base, AutoBaseMixin, ReleaseMixin):
"""
__tablename__ = "transducer_observation"
+ # Unique rather than merely indexed: without a constraint to conflict on, a
+ # re-run can only avoid duplicates by deleting first, which leaves a window
+ # where the data is missing. With it the loader upserts and a repeated
+ # backfill is idempotent.
+ #
+ # Scoped to the deployment, not the thing: a deployment is a thing/sensor
+ # pairing, so two sensors on one well may legitimately report the same
+ # instant.
__table_args__ = (
- Index(
- "ix_transducer_observation_deployment_parameter_datetime",
+ UniqueConstraint(
"deployment_id",
"parameter_id",
"observation_datetime",
+ name="uq_transducer_observation_deployment_parameter_datetime",
),
)
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index 7a3773081..f7dd5225c 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -277,13 +277,18 @@ Some of the 33 may already exist in Ocotillo under Bureau point IDs. Duplicates
### 3.4 — Unique constraint on `transducer_observation` + idempotent upsert loader
-`db/transducer.py` defines only an index — no unique constraint, so nothing prevents inserting the same reading twice. That absence is what forces Aqueduct's delete-then-repost in FROST.
+Built. Migration `a1b2c3d4e5f6`, loader in `automated_ingestion/ocotillo/loader.py`, three tests against a real Postgres.
-- Alembic migration adds `UniqueConstraint(thing_id, parameter_id, observation_datetime)`. Existing duplicates found and resolved first — the migration must not fail on production data.
-- Loader batches and issues `INSERT … ON CONFLICT … DO UPDATE`, through the `db/` SQLAlchemy models, not raw SQL. Batch size tuned and documented; a full backfill month fits in memory; each batch commits in its own transaction.
-- `TransducerObservationBlock` rows created/extended for the loaded window, `review_status = "not reviewed"`.
-- Loader reports rows inserted, rows updated, adapter failures as Dagster metadata.
-- Test: loading the same window twice leaves the row count unchanged.
+**The constraint is on `deployment_id`, not `thing_id`.** This section named a column the table does not have — `TransducerObservation` carries `deployment_id`, and `thing_id` lives on `TransducerObservationBlock`. The existing index was already `(deployment_id, parameter_id, observation_datetime)`, so the constraint matches it. Semantically this is also the right scope: a deployment is a thing/sensor pairing, so two sensors on one well may legitimately report the same instant.
+
+- ✅ Migration drops the redundant index — the unique constraint creates its own on the same columns, and keeping both means two indexes maintained on every insert into the largest table in the schema. Verified up and down against a database with 88,666 observations.
+- ✅ `automated_ingestion/sql/find_duplicate_observations.sql` reports violations **before** the migration runs, since it fails on a table that already violates it and failing halfway through a production migration is worse than not starting. It separates redundant copies from groups whose `value` disagrees — the latter are not duplicates but conflicting measurements, and collapsing them silently would discard a reading.
+- ✅ Loader upserts with `ON CONFLICT DO UPDATE`, batching at 5,000 rows and committing per batch. **`DO UPDATE`, not `DO NOTHING`:** a vendor correction arriving as a no-op would leave the old value in place while the run reported success.
+- ✅ SQLAlchemy Core, not ORM objects, per `AGENTS.md` — instantiating a mapped class per observation is what turns a backfill into an hour-long run.
+- ✅ `ensure_block` widens an existing block rather than duplicating it, and defaults `review_status` to `not reviewed`.
+- ✅ Test: loading the same window twice leaves the row count unchanged. That claim depends on Postgres enforcing the constraint, so it runs against the real database rather than a stub.
+
+⬜ Run the duplicate report against production and staging before applying the migration. The local development database was clean — 0 duplicate groups in 88,666 rows — which is encouraging and not evidence about production.
### 3.5 — Watermark from Postgres
diff --git a/tests/test_transducer_loader.py b/tests/test_transducer_loader.py
new file mode 100644
index 000000000..f6cefdc0d
--- /dev/null
+++ b/tests/test_transducer_loader.py
@@ -0,0 +1,117 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Loader behaviour against a real database.
+
+Idempotency is the whole point of the unique constraint and cannot be shown with
+a stub: it depends on Postgres enforcing the constraint and on ON CONFLICT
+resolving against it.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import delete, func, select
+
+from automated_ingestion.ocotillo.loader import load_observations
+from automated_ingestion.ocotillo.structs import ObservationRecord
+from db.engine import session_ctx
+from db.parameter import Parameter
+from db.transducer import TransducerObservation
+
+START = datetime(2026, 1, 1, tzinfo=timezone.utc)
+
+
+@pytest.fixture()
+def loader_target(sensor_to_water_well_thing_deployment):
+ """A deployment and parameter to load against, cleaned up afterwards."""
+ deployment_id = sensor_to_water_well_thing_deployment.id
+ with session_ctx() as session:
+ parameter_id = session.scalar(select(Parameter.id).limit(1))
+ assert parameter_id, "lexicon parameters are seeded by conftest"
+ yield deployment_id, parameter_id
+ session.execute(
+ delete(TransducerObservation).where(
+ TransducerObservation.deployment_id == deployment_id
+ )
+ )
+ session.commit()
+
+
+def _records(count, value=10.0):
+ return [
+ ObservationRecord(
+ external_point_id="sanacaciareach-40",
+ observation_datetime=START + timedelta(minutes=5 * i),
+ value=value + i,
+ units="ft",
+ )
+ for i in range(count)
+ ]
+
+
+def _count(session, deployment_id):
+ return session.scalar(
+ select(func.count())
+ .select_from(TransducerObservation)
+ .where(TransducerObservation.deployment_id == deployment_id)
+ )
+
+
+def test_loading_the_same_window_twice_does_not_duplicate(loader_target):
+ deployment_id, parameter_id = loader_target
+ with session_ctx() as session:
+ load_observations(session, _records(10), deployment_id, parameter_id, "draft")
+ load_observations(session, _records(10), deployment_id, parameter_id, "draft")
+ assert _count(session, deployment_id) == 10
+
+
+def test_a_corrected_value_overwrites_rather_than_being_ignored(loader_target):
+ # DO NOTHING would leave the old reading in place while the run reported
+ # success -- the worst of both outcomes.
+ deployment_id, parameter_id = loader_target
+ with session_ctx() as session:
+ load_observations(
+ session, _records(1, value=10.0), deployment_id, parameter_id, "draft"
+ )
+ load_observations(
+ session, _records(1, value=99.0), deployment_id, parameter_id, "draft"
+ )
+ stored = session.scalar(
+ select(TransducerObservation.value).where(
+ TransducerObservation.deployment_id == deployment_id
+ )
+ )
+ assert stored == 99.0
+
+
+def test_batches_commit_separately(loader_target):
+ deployment_id, parameter_id = loader_target
+ with session_ctx() as session:
+ result = load_observations(
+ session,
+ _records(25),
+ deployment_id,
+ parameter_id,
+ "draft",
+ batch_size=10,
+ )
+ assert result.batches == 3
+ assert result.rows_written == 25
+ assert _count(session, deployment_id) == 25
+
+
+# ============= EOF =============================================
From a0af3127d52d97c2baa0f795b0b203c362cbd891 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 21:58:29 -0700
Subject: [PATCH 091/151] feat(ingestion): derive the watermark from Postgres
Aqueduct keeps watermarks in a GCS object because FROST cannot be queried
cheaply for a maximum. Ocotillo's destination can, so the watermark is
MAX(observation_datetime) for the series.
That is a deliberate divergence. A stored watermark is a second source of truth
about what was loaded, and the two drift: a half-succeeded load, or a sidecar
write that fails after the rows commit, leaves it claiming more or less than the
data holds. A derived one cannot disagree with reality.
It also turns "backfill never advances the normal watermark" from a rule to
enforce into a property that cannot be violated -- re-loading a window behind
the maximum cannot move a maximum forward. Tested in both directions anyway,
older data and the same window twice, since the claim is load-bearing for
task 4.
Keyed by thing rather than deployment. Observations carry deployment_id, but a
series outlives its hardware: replacing a diver creates a new deployment for the
same well, and a watermark keyed to the deployment would report nothing for the
new one and re-fetch the entire history.
The floor applies only to a series that has never been loaded, in either
direction -- a floor ahead of the watermark does not win either, so it cannot be
used as a backfill lever by accident.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/shared/watermark.py | 108 ++++++++++++++++++
docs/automated-ingestion-pipeline-plan.md | 16 ++-
tests/test_watermark.py | 131 ++++++++++++++++++++++
3 files changed, 251 insertions(+), 4 deletions(-)
create mode 100644 automated_ingestion/shared/watermark.py
create mode 100644 tests/test_watermark.py
diff --git a/automated_ingestion/shared/watermark.py b/automated_ingestion/shared/watermark.py
new file mode 100644
index 000000000..3dfcdfebb
--- /dev/null
+++ b/automated_ingestion/shared/watermark.py
@@ -0,0 +1,108 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Where a series got to, asked of the data rather than of a sidecar.
+
+Aqueduct keeps watermarks in a GCS object beside the raw zone, because its
+destination is FROST and cannot be queried cheaply for a maximum. Ocotillo's
+destination is Postgres, so the watermark is simply
+``MAX(observation_datetime)`` for the series.
+
+**This is a deliberate divergence, not an oversight.** A stored watermark is a
+second source of truth about what was loaded, and the two drift: a load that
+half-succeeds, or a sidecar write that fails after the rows commit, leaves the
+watermark claiming more or less than the data holds. Deriving it means the
+answer cannot disagree with reality — and it makes a backfill safe by
+construction, since re-loading an old window cannot move a maximum forward.
+
+**Keyed by thing, not by deployment.** Observations carry ``deployment_id``, but
+a series outlives its hardware: replacing a diver creates a new deployment for
+the same well, and a watermark keyed to the deployment would report nothing for
+the new one and re-fetch the entire history. The query joins through
+``deployment`` to ask the question the pipeline actually has -- how far along is
+this well's depth-to-water record.
+"""
+
+from datetime import datetime
+from typing import Any, Protocol
+
+
+class WatermarkStore(Protocol):
+ """Where a series has been loaded up to."""
+
+ def get(self, thing_id: int, parameter_id: int) -> datetime | None:
+ """Latest observation for the series, or ``None`` if never loaded."""
+ ...
+
+
+class PostgresWatermarkStore:
+ """Reads the watermark from the observations themselves.
+
+ Takes the session the loader is using, so the watermark reflects that
+ session's committed state rather than a separate connection's snapshot.
+ """
+
+ def __init__(self, session: Any) -> None:
+ self._session = session
+
+ def get(self, thing_id: int, parameter_id: int) -> datetime | None:
+ from sqlalchemy import func, select
+
+ from db.deployment import Deployment
+ from db.transducer import TransducerObservation
+
+ return self._session.scalar(
+ select(func.max(TransducerObservation.observation_datetime))
+ .join(
+ Deployment,
+ Deployment.id == TransducerObservation.deployment_id,
+ )
+ .where(Deployment.thing_id == thing_id)
+ .where(TransducerObservation.parameter_id == parameter_id)
+ )
+
+
+class InMemoryWatermarkStore:
+ """For tests, and for reasoning about a run without a database."""
+
+ def __init__(self, watermarks: dict[tuple[int, int], datetime] | None = None):
+ self._watermarks = dict(watermarks or {})
+
+ def get(self, thing_id: int, parameter_id: int) -> datetime | None:
+ return self._watermarks.get((thing_id, parameter_id))
+
+ def set(self, thing_id: int, parameter_id: int, value: datetime) -> None:
+ self._watermarks[(thing_id, parameter_id)] = value
+
+
+def resolve_start(
+ store: WatermarkStore,
+ thing_id: int,
+ parameter_id: int,
+ floor: datetime,
+) -> datetime:
+ """Where the next fetch should begin.
+
+ ``floor`` applies only to a series that has never been loaded. It is not a
+ backfill lever: lowering it will not re-fetch history for a series whose
+ watermark has already advanced past it, because the watermark wins whenever
+ one exists. Re-fetching history is what the backfill jobs are for.
+ """
+ watermark = store.get(thing_id, parameter_id)
+ return watermark if watermark is not None else floor
+
+
+# ============= EOF =============================================
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index f7dd5225c..6213285e2 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -292,10 +292,18 @@ Built. Migration `a1b2c3d4e5f6`, loader in `automated_ingestion/ocotillo/loader.
### 3.5 — Watermark from Postgres
-- Keep Aqueduct's `WatermarkStore` interface; Postgres implementation returns `MAX(observation_datetime)` for a `(thing_id, parameter_id)`, read in the same session as the write. No GCS sidecar for normal runs.
-- Backfill never advances the normal watermark implicitly — inherent with upsert, but asserted in a test.
-- In-memory implementation kept for tests. First-ever run for a series falls back to the `initial_start_date` floor.
-- Divergence from Aqueduct recorded in the module docstring, so it reads as a decision not an oversight.
+Built. `automated_ingestion/shared/watermark.py`, seven tests.
+
+- ✅ `PostgresWatermarkStore` returns `MAX(observation_datetime)` for the series, read through the loader's own session so it reflects that session's committed state rather than another connection's snapshot.
+- ✅ `InMemoryWatermarkStore` for tests and for reasoning about a run without a database.
+- ✅ `resolve_start` falls back to the `initial_start_date` floor only for a series that has never been loaded. A test asserts a floor *ahead* of the watermark does not win either — the floor is not a backfill lever in any direction.
+- ✅ The divergence from Aqueduct is in the module docstring, so it reads as a decision rather than an oversight.
+
+**Keyed by thing, not deployment.** This section said `(thing_id, parameter_id)` and that turns out to be right for a reason worth stating: observations carry `deployment_id`, but a series outlives its hardware. Replacing a diver creates a new deployment for the same well, and a watermark keyed to the deployment would report nothing for the new one and re-fetch the entire history. The query joins through `deployment` to ask the question the pipeline actually has.
+
+**Why derive rather than store.** A stored watermark is a second source of truth about what was loaded, and the two drift — a half-succeeded load, or a sidecar write that fails after the rows commit, leaves it claiming more or less than the data holds. Aqueduct stores one because FROST cannot be queried cheaply for a maximum; Postgres can.
+
+The payoff is that "backfill never advances the normal watermark" stops being a rule to enforce and becomes a property that cannot be violated: re-loading a window behind the maximum cannot move a maximum forward. Asserted anyway, in two directions — older data, and the same window twice.
---
diff --git a/tests/test_watermark.py b/tests/test_watermark.py
new file mode 100644
index 000000000..e2606de1e
--- /dev/null
+++ b/tests/test_watermark.py
@@ -0,0 +1,131 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Watermark behaviour, including the property that makes backfill safe.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import delete, select
+
+from automated_ingestion.ocotillo.loader import load_observations
+from automated_ingestion.ocotillo.structs import ObservationRecord
+from automated_ingestion.shared.watermark import (
+ InMemoryWatermarkStore,
+ PostgresWatermarkStore,
+ resolve_start,
+)
+from db.engine import session_ctx
+from db.parameter import Parameter
+from db.transducer import TransducerObservation
+
+START = datetime(2026, 1, 1, tzinfo=timezone.utc)
+FLOOR = datetime(2015, 1, 1, tzinfo=timezone.utc)
+
+
+@pytest.fixture()
+def series(sensor_to_water_well_thing_deployment):
+ """A deployment, its thing, and a parameter, cleaned up afterwards."""
+ deployment = sensor_to_water_well_thing_deployment
+ with session_ctx() as session:
+ parameter_id = session.scalar(select(Parameter.id).limit(1))
+ yield deployment.id, deployment.thing_id, parameter_id
+ session.execute(
+ delete(TransducerObservation).where(
+ TransducerObservation.deployment_id == deployment.id
+ )
+ )
+ session.commit()
+
+
+def _records(count, start=START, value=10.0):
+ return [
+ ObservationRecord(
+ external_point_id="sanacaciareach-40",
+ observation_datetime=start + timedelta(minutes=5 * i),
+ value=value,
+ units="ft",
+ )
+ for i in range(count)
+ ]
+
+
+class TestPostgresWatermark:
+ def test_unloaded_series_has_no_watermark(self, series):
+ deployment_id, thing_id, parameter_id = series
+ with session_ctx() as session:
+ store = PostgresWatermarkStore(session)
+ assert store.get(thing_id, parameter_id) is None
+
+ def test_watermark_is_the_latest_observation(self, series):
+ deployment_id, thing_id, parameter_id = series
+ with session_ctx() as session:
+ load_observations(
+ session, _records(10), deployment_id, parameter_id, "draft"
+ )
+ store = PostgresWatermarkStore(session)
+ assert store.get(thing_id, parameter_id) == START + timedelta(minutes=45)
+
+ def test_backfilling_older_data_does_not_advance_it(self, series):
+ # The property that makes backfill safe: a watermark derived from the
+ # data cannot be moved forward by re-loading a window behind it. A
+ # stored watermark has to be defended against this; a derived one
+ # cannot have the problem.
+ deployment_id, thing_id, parameter_id = series
+ with session_ctx() as session:
+ load_observations(
+ session, _records(10), deployment_id, parameter_id, "draft"
+ )
+ store = PostgresWatermarkStore(session)
+ before = store.get(thing_id, parameter_id)
+
+ older = _records(10, start=START - timedelta(days=365))
+ load_observations(session, older, deployment_id, parameter_id, "draft")
+
+ assert store.get(thing_id, parameter_id) == before
+
+ def test_reloading_the_same_window_does_not_move_it(self, series):
+ deployment_id, thing_id, parameter_id = series
+ with session_ctx() as session:
+ load_observations(
+ session, _records(5), deployment_id, parameter_id, "draft"
+ )
+ store = PostgresWatermarkStore(session)
+ before = store.get(thing_id, parameter_id)
+ load_observations(
+ session, _records(5), deployment_id, parameter_id, "draft"
+ )
+ assert store.get(thing_id, parameter_id) == before
+
+
+class TestResolveStart:
+ def test_floor_applies_only_to_a_new_series(self):
+ assert resolve_start(InMemoryWatermarkStore(), 1, 2, FLOOR) == FLOOR
+
+ def test_watermark_wins_over_the_floor(self):
+ # The floor is not a backfill lever: lowering it must not re-fetch
+ # history for a series that has already advanced past it.
+ store = InMemoryWatermarkStore({(1, 2): START})
+ assert resolve_start(store, 1, 2, FLOOR) == START
+
+ def test_a_floor_ahead_of_the_watermark_does_not_win_either(self):
+ store = InMemoryWatermarkStore({(1, 2): START})
+ ahead = START + timedelta(days=365)
+ assert resolve_start(store, 1, 2, ahead) == START
+
+
+# ============= EOF =============================================
From 6d1ced36e8965ed313f7b10b162cb229716adc87 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 22:30:21 -0700
Subject: [PATCH 092/151] feat(ingestion): add the shared backfill primitives
Chunking, run keys, location validation, and checkpoint tracking for both
backfill modes. All pure, so the logic is testable without object storage or a
database.
Written from this plan's description rather than ported line by line: the
Aqueduct checkout available here is an empty directory skeleton. Each docstring
records provenance and what differs so the two can still be diffed.
ChunkResult counts rows_upserted rather than Aqueduct's posted/deleted pair.
That is not a rename. Aqueduct deletes a window and re-posts it because FROST
has no constraint to conflict on, which gives it two numbers and a window where
the data is missing; with the constraint from 3.4 Ocotillo upserts, so there is
one number and no window.
Three behaviours are deliberate and tested. Chunk edges clip rather than widen,
so a backfill does not fetch data nobody asked for. An empty or reversed window
is rejected rather than treated as a no-op, because a run that succeeds having
done nothing looks exactly like one that worked. An unknown location id fails
the run naming every bad id, for the same reason.
Run keys are sanitized before becoming path segments: one containing a slash
would write checkpoints somewhere a resumed run would not look for them.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/shared/backfill.py | 233 ++++++++++++++++++++-
automated_ingestion/tests/test_backfill.py | 179 ++++++++++++++++
docs/automated-ingestion-pipeline-plan.md | 20 +-
3 files changed, 424 insertions(+), 8 deletions(-)
create mode 100644 automated_ingestion/tests/test_backfill.py
diff --git a/automated_ingestion/shared/backfill.py b/automated_ingestion/shared/backfill.py
index e884f36f2..6799bdad5 100644
--- a/automated_ingestion/shared/backfill.py
+++ b/automated_ingestion/shared/backfill.py
@@ -14,11 +14,236 @@
# limitations under the License.
# ===============================================================================
"""
-Backfill primitives shared by every source.
+Primitives shared by every backfill, in either mode.
-Ported from Aqueduct under BDMS task 4.1 -- ``month_chunks``, ``ChunkResult``,
-and ``BackfillCheckpointStore``. Ported rather than imported: the two
-repositories deploy separately and are allowed to diverge.
+Ported from Aqueduct's ``shared/backfill.py`` rather than imported: the two
+repositories deploy separately and are allowed to diverge. Where behaviour
+differs from the original it is called out on the function, so the two can be
+diffed later by someone who has both open.
+
+**Changed from Aqueduct.** ``ChunkResult`` counts ``rows_upserted`` where the
+original counted ``observations_posted`` and ``observations_deleted``. That is
+not a rename: Aqueduct deletes a window and re-posts it because FROST has no
+constraint to conflict on, so it has two numbers and a window during which the
+data is missing. Ocotillo upserts, so there is one number and no window.
+
+Everything here is pure except the checkpoint store, which is why the store is
+an interface with an in-memory implementation -- a backfill's chunking and
+resumption logic can then be tested without touching object storage.
"""
+import re
+from calendar import monthrange
+from collections.abc import Iterable, Iterator
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from typing import Any, Protocol
+
+
+@dataclass(frozen=True)
+class Chunk:
+ """One calendar month of a backfill window, half-open at the end."""
+
+ start: datetime
+ end: datetime
+
+ @property
+ def key(self) -> str:
+ """Stable identifier, used for checkpointing."""
+ return self.start.strftime("%Y-%m")
+
+
+@dataclass
+class ChunkResult:
+ """What one chunk did.
+
+ ``rows_upserted`` replaces Aqueduct's posted/deleted pair -- see the module
+ docstring. ``failures`` counts records the adapter refused, which are
+ per-record and never fatal to the chunk.
+ """
+
+ chunk_key: str
+ rows_ingested: int = 0
+ rows_upserted: int = 0
+ failures: int = 0
+
+ @property
+ def rows_refused(self) -> int:
+ return self.rows_ingested - self.rows_upserted
+
+
+@dataclass
+class BackfillTotals:
+ """Sum across chunks, for run-level metadata."""
+
+ chunks: int = 0
+ rows_ingested: int = 0
+ rows_upserted: int = 0
+ failures: int = 0
+ chunk_keys: list[str] = field(default_factory=list)
+
+
+def month_chunks(start: datetime, end: datetime) -> Iterator[Chunk]:
+ """Split a window into calendar months.
+
+ Calendar months rather than fixed-length windows because that is how a human
+ describes a gap ("we lost March"), and because it makes a chunk key legible
+ in a checkpoint file. The first and last chunks are clipped to the requested
+ range rather than widened to whole months -- widening would fetch data the
+ operator did not ask for.
+ """
+ validate_date_order(start, end)
+
+ cursor = start
+ while cursor < end:
+ _, last_day = monthrange(cursor.year, cursor.month)
+ month_end = cursor.replace(
+ day=last_day, hour=23, minute=59, second=59, microsecond=999999
+ )
+ chunk_end = min(month_end, end)
+ yield Chunk(start=cursor, end=chunk_end)
+
+ if chunk_end >= end:
+ return
+ year = cursor.year + (1 if cursor.month == 12 else 0)
+ month = 1 if cursor.month == 12 else cursor.month + 1
+ cursor = cursor.replace(
+ year=year, month=month, day=1, hour=0, minute=0, second=0, microsecond=0
+ )
+
+
+def sum_chunk_results(results: Iterable[ChunkResult]) -> BackfillTotals:
+ """Aggregate chunk results for reporting."""
+ totals = BackfillTotals()
+ for result in results:
+ totals.chunks += 1
+ totals.rows_ingested += result.rows_ingested
+ totals.rows_upserted += result.rows_upserted
+ totals.failures += result.failures
+ totals.chunk_keys.append(result.chunk_key)
+ return totals
+
+
+def parse_backfill_date(value: str) -> datetime:
+ """Parse an operator-supplied date into a timezone-aware UTC datetime.
+
+ A bare date means midnight UTC. Accepting a naive value and treating it as
+ local time would make the same run config mean different windows on
+ different machines.
+ """
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError(f"Backfill date is missing or blank: {value!r}")
+ try:
+ parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
+ except ValueError as exc:
+ raise ValueError(f"Backfill date {value!r} is not ISO-8601.") from exc
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.astimezone(timezone.utc)
+
+
+def validate_date_order(start: datetime, end: datetime) -> None:
+ """Reject a reversed or empty window.
+
+ An empty window is rejected rather than treated as a no-op: a backfill that
+ reports success having done nothing is indistinguishable from one that
+ worked, and the operator would not learn they typed the dates backwards.
+ """
+ if end <= start:
+ raise ValueError(
+ f"Backfill end {end.isoformat()} must be after start {start.isoformat()}."
+ )
+
+
+_UNSAFE_RUN_KEY = re.compile(r"[^A-Za-z0-9._-]+")
+
+
+def sanitize_run_key(value: str) -> str:
+ """Reduce an operator-supplied run key to something safe as a path segment.
+
+ Run keys end up in object storage paths. An unsanitized one containing a
+ slash would silently write checkpoints into a directory of its own, and a
+ resumed run would not find them.
+ """
+ cleaned = _UNSAFE_RUN_KEY.sub("-", (value or "").strip()).strip("-")
+ if not cleaned:
+ raise ValueError(f"Run key {value!r} contains nothing usable.")
+ return cleaned
+
+
+def attach_run_timestamp(run_key: str, now: datetime | None = None) -> str:
+ """Append a UTC timestamp, so two runs with the same key stay distinct.
+
+ Only for keys that are *not* meant to resume. Resumption depends on the key
+ being stable, so the caller decides; this never applies it silently.
+ """
+ stamp = (now or datetime.now(tz=timezone.utc)).strftime("%Y%m%dT%H%M%SZ")
+ return f"{sanitize_run_key(run_key)}-{stamp}"
+
+
+def chunk_key(run_key: str, chunk: Chunk) -> str:
+ """Checkpoint identifier for one chunk of one run."""
+ return f"{sanitize_run_key(run_key)}/{chunk.key}"
+
+
+def resolve_location_ids(
+ requested: Iterable[Any], available: Iterable[Any]
+) -> list[Any]:
+ """Validate requested locations against what the source offers.
+
+ An empty request means every available location. An unknown id fails the
+ run, naming the bad ids -- Aqueduct's behaviour, and worth keeping: silently
+ backfilling nothing looks identical to backfilling successfully, and the
+ operator finds out weeks later that the gap is still there.
+ """
+ available_list = list(available)
+ requested_list = [r for r in requested] if requested is not None else []
+ if not requested_list:
+ return available_list
+
+ known = set(available_list)
+ unknown = [r for r in requested_list if r not in known]
+ if unknown:
+ raise ValueError(
+ "Unknown location ids: "
+ + ", ".join(str(u) for u in sorted(unknown, key=str))
+ + ". Nothing was backfilled."
+ )
+ return [r for r in requested_list]
+
+
+class CheckpointStore(Protocol):
+ """Which chunks of a run have completed."""
+
+ def completed(self, run_key: str) -> set[str]: ...
+
+ def mark_complete(self, run_key: str, chunk: Chunk) -> None: ...
+
+
+class InMemoryCheckpointStore:
+ """For tests, and for a dry run that must not persist anything."""
+
+ def __init__(self) -> None:
+ self._done: dict[str, set[str]] = {}
+
+ def completed(self, run_key: str) -> set[str]:
+ return set(self._done.get(sanitize_run_key(run_key), set()))
+
+ def mark_complete(self, run_key: str, chunk: Chunk) -> None:
+ self._done.setdefault(sanitize_run_key(run_key), set()).add(chunk.key)
+
+
+def pending_chunks(
+ store: CheckpointStore, run_key: str, chunks: Iterable[Chunk]
+) -> list[Chunk]:
+ """Chunks of this run that have not completed yet.
+
+ A chunk is checkpointed only after ingest, transform, and load have all
+ succeeded, so anything not marked is safe to redo -- the load is an upsert,
+ and redoing a partially loaded chunk rewrites the same rows.
+ """
+ done = store.completed(run_key)
+ return [chunk for chunk in chunks if chunk.key not in done]
+
+
# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_backfill.py b/automated_ingestion/tests/test_backfill.py
new file mode 100644
index 000000000..6b9a88808
--- /dev/null
+++ b/automated_ingestion/tests/test_backfill.py
@@ -0,0 +1,179 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Backfill primitives. Pure, so none of this needs a database or a network.
+"""
+
+from datetime import datetime, timezone
+
+import pytest
+
+from automated_ingestion.shared.backfill import (
+ Chunk,
+ ChunkResult,
+ InMemoryCheckpointStore,
+ attach_run_timestamp,
+ chunk_key,
+ month_chunks,
+ parse_backfill_date,
+ pending_chunks,
+ resolve_location_ids,
+ sanitize_run_key,
+ sum_chunk_results,
+ validate_date_order,
+)
+
+
+def _utc(y, m, d):
+ return datetime(y, m, d, tzinfo=timezone.utc)
+
+
+class TestMonthChunks:
+ def test_window_is_split_on_calendar_months(self):
+ chunks = list(month_chunks(_utc(2026, 1, 15), _utc(2026, 4, 10)))
+ assert [c.key for c in chunks] == ["2026-01", "2026-02", "2026-03", "2026-04"]
+
+ def test_edges_are_clipped_not_widened(self):
+ # Widening would fetch data the operator did not ask for.
+ chunks = list(month_chunks(_utc(2026, 1, 15), _utc(2026, 2, 10)))
+ assert chunks[0].start == _utc(2026, 1, 15)
+ assert chunks[-1].end == _utc(2026, 2, 10)
+
+ def test_chunks_do_not_overlap(self):
+ chunks = list(month_chunks(_utc(2025, 11, 3), _utc(2026, 3, 20)))
+ for earlier, later in zip(chunks, chunks[1:]):
+ assert earlier.end < later.start
+
+ def test_window_inside_one_month_is_a_single_chunk(self):
+ assert len(list(month_chunks(_utc(2026, 1, 5), _utc(2026, 1, 20)))) == 1
+
+ def test_year_boundary(self):
+ chunks = list(month_chunks(_utc(2025, 12, 20), _utc(2026, 1, 10)))
+ assert [c.key for c in chunks] == ["2025-12", "2026-01"]
+
+ def test_reversed_window_is_rejected(self):
+ with pytest.raises(ValueError, match="must be after"):
+ list(month_chunks(_utc(2026, 5, 1), _utc(2026, 1, 1)))
+
+
+class TestDates:
+ def test_bare_date_is_utc_midnight(self):
+ assert parse_backfill_date("2026-01-15") == _utc(2026, 1, 15)
+
+ def test_naive_datetime_is_read_as_utc(self):
+ # The same run config must mean the same window on every machine.
+ assert parse_backfill_date("2026-01-15T00:00:00") == _utc(2026, 1, 15)
+
+ def test_offset_is_normalized(self):
+ assert parse_backfill_date("2026-01-14T18:00:00-06:00") == _utc(2026, 1, 15)
+
+ @pytest.mark.parametrize("value", ["", " ", "yesterday", None])
+ def test_unusable_dates_are_rejected(self, value):
+ with pytest.raises(ValueError):
+ parse_backfill_date(value)
+
+ def test_empty_window_is_rejected(self):
+ # A backfill that succeeds having done nothing is indistinguishable from
+ # one that worked, and the operator never learns they typed the dates
+ # backwards.
+ with pytest.raises(ValueError):
+ validate_date_order(_utc(2026, 1, 1), _utc(2026, 1, 1))
+
+
+class TestRunKeys:
+ def test_path_separators_are_removed(self):
+ # An unsanitized key with a slash writes checkpoints into a directory of
+ # its own, and a resumed run does not find them.
+ assert "/" not in sanitize_run_key("march/gap")
+
+ def test_unusable_keys_are_rejected(self):
+ with pytest.raises(ValueError):
+ sanitize_run_key("///")
+
+ def test_timestamp_is_appended_only_when_asked(self):
+ stamped = attach_run_timestamp("gap", now=_utc(2026, 1, 15))
+ assert stamped == "gap-20260115T000000Z"
+
+ def test_chunk_key_combines_run_and_month(self):
+ chunk = Chunk(start=_utc(2026, 3, 1), end=_utc(2026, 3, 31))
+ assert chunk_key("march gap", chunk) == "march-gap/2026-03"
+
+
+class TestLocationIds:
+ def test_empty_request_means_everything(self):
+ assert resolve_location_ids([], [39, 40, 41]) == [39, 40, 41]
+
+ def test_unknown_ids_fail_the_run(self):
+ # Silently backfilling nothing looks identical to backfilling
+ # successfully, and the gap is still there weeks later.
+ with pytest.raises(ValueError, match="99"):
+ resolve_location_ids([39, 99], [39, 40])
+
+ def test_error_names_every_bad_id(self):
+ with pytest.raises(ValueError) as exc:
+ resolve_location_ids([98, 99], [39])
+ assert "98" in str(exc.value) and "99" in str(exc.value)
+
+ def test_requested_subset_is_preserved(self):
+ assert resolve_location_ids([41, 39], [39, 40, 41]) == [41, 39]
+
+
+class TestCheckpoints:
+ def test_pending_excludes_completed(self):
+ store = InMemoryCheckpointStore()
+ chunks = list(month_chunks(_utc(2026, 1, 1), _utc(2026, 4, 1)))
+ store.mark_complete("gap", chunks[0])
+ assert [c.key for c in pending_chunks(store, "gap", chunks)] == [
+ "2026-02",
+ "2026-03",
+ ]
+
+ def test_checkpoints_are_scoped_to_the_run(self):
+ store = InMemoryCheckpointStore()
+ chunks = list(month_chunks(_utc(2026, 1, 1), _utc(2026, 3, 1)))
+ store.mark_complete("gap", chunks[0])
+ assert len(pending_chunks(store, "other", chunks)) == 2
+
+ def test_run_key_is_sanitized_consistently(self):
+ # Marking under one spelling and resuming under another must not lose
+ # the checkpoint.
+ store = InMemoryCheckpointStore()
+ chunk = Chunk(start=_utc(2026, 1, 1), end=_utc(2026, 1, 31))
+ store.mark_complete("march gap", chunk)
+ assert store.completed("march-gap") == {"2026-01"}
+
+
+class TestTotals:
+ def test_totals_sum_across_chunks(self):
+ totals = sum_chunk_results(
+ [
+ ChunkResult("2026-01", rows_ingested=100, rows_upserted=98, failures=2),
+ ChunkResult("2026-02", rows_ingested=50, rows_upserted=50),
+ ]
+ )
+ assert totals.chunks == 2
+ assert totals.rows_ingested == 150
+ assert totals.rows_upserted == 148
+ assert totals.failures == 2
+ assert totals.chunk_keys == ["2026-01", "2026-02"]
+
+ def test_refused_rows_are_derived(self):
+ assert (
+ ChunkResult("2026-01", rows_ingested=10, rows_upserted=7).rows_refused == 3
+ )
+
+
+# ============= EOF =============================================
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index 7a3773081..5394afe95 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -302,10 +302,22 @@ A forward-only pipeline isn't enough. `BACKFILL_STRATEGY.md` §3 lists twelve si
### 4.1 — Port shared backfill primitives from Aqueduct
-- `month_chunks()`, `ChunkResult`, `sum_chunk_results()`, `parse_backfill_date()`, `validate_date_order()`, `attach_run_timestamp()`, `sanitize_run_key()`, `resolve_location_ids()`, `chunk_key()`, `BackfillCheckpointStore` → `automated_ingestion/shared/backfill.py`. `atomic_write_json_with_retry()` → `shared/gcs.py`.
-- `ChunkResult` adjusted for Postgres: `rows_upserted` replaces `observations_posted`/`observations_deleted`.
-- Aqueduct's tests ported alongside and passing.
-- Each docstring notes provenance and what changed, so the two can be diffed later.
+Built. `automated_ingestion/shared/backfill.py`, 27 tests, all pure — no database, no network.
+
+**Written from the described shape, not copied.** The Aqueduct checkout available here was an empty directory skeleton, so these were rebuilt from this plan's description rather than ported line by line. Each docstring records provenance and what differs, so the two can still be diffed by someone with both open.
+
+- ✅ `month_chunks`, `Chunk`, `ChunkResult`, `sum_chunk_results`, `parse_backfill_date`, `validate_date_order`, `attach_run_timestamp`, `sanitize_run_key`, `chunk_key`, `resolve_location_ids`, `CheckpointStore` + `InMemoryCheckpointStore`, `pending_chunks`.
+- ⬜ `atomic_write_json_with_retry()` and a GCS-backed checkpoint store — deferred until 4.2 needs persistence. The interface is in place so the logic is testable without object storage.
+
+**`ChunkResult` counts `rows_upserted`,** replacing Aqueduct's `observations_posted` / `observations_deleted`. Not a rename: Aqueduct deletes a window and re-posts it because FROST has no constraint to conflict on, so it has two numbers and a window during which the data is missing. With 3.4's constraint Ocotillo upserts — one number, no window.
+
+Behaviour worth knowing:
+
+- **Chunk edges are clipped, not widened.** A window starting mid-month yields a first chunk starting mid-month, because widening would fetch data the operator did not ask for.
+- **An empty or reversed window is rejected.** A backfill that reports success having done nothing is indistinguishable from one that worked, and the operator would not learn they typed the dates backwards.
+- **An unknown `location_id` fails the run, naming every bad id.** Kept from Aqueduct deliberately: silently backfilling nothing looks exactly like backfilling successfully, and the gap is still there weeks later.
+- **Run keys are sanitized** before becoming path segments. One containing a slash would write checkpoints into a directory of its own, and a resumed run would not find them. Marking under `march gap` and resuming under `march-gap` finds the same checkpoint.
+- **A naive date is read as UTC**, so the same run config means the same window on every machine.
### 4.2 — Backfill Mode A (refetch)
From 54b0611781cad1aae472c31b995d4ac4f54bc133 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 22:55:27 -0700
Subject: [PATCH 093/151] chore(ingestion): report duplicate observations
without psql
The SQL version of this check needs a psql client and a database password,
neither of which is available on a machine that has only gcloud. This does the
same counting through the Cloud SQL connector with IAM authentication, so the
credential is the operator's own login and nothing is stored.
It reports and does not resolve. Which copy to keep is a judgement about the
data: groups whose values disagree are conflicting measurements rather than
redundant rows, and collapsing them would discard a reading somebody recorded.
The failure path is the useful part -- a principal without a database user gets
a Postgres authentication error that reads like a bad password, so the message
names the gcloud command that creates one.
Co-Authored-By: Claude Opus 5
---
.../scripts/report_duplicate_observations.py | 153 ++++++++++++++++++
1 file changed, 153 insertions(+)
create mode 100644 automated_ingestion/scripts/report_duplicate_observations.py
diff --git a/automated_ingestion/scripts/report_duplicate_observations.py b/automated_ingestion/scripts/report_duplicate_observations.py
new file mode 100644
index 000000000..96ffbba94
--- /dev/null
+++ b/automated_ingestion/scripts/report_duplicate_observations.py
@@ -0,0 +1,153 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Report duplicate transducer observations before the unique constraint migration.
+
+Does what ``sql/find_duplicate_observations.sql`` does, without needing ``psql``
+or a password: it connects through the Cloud SQL connector with IAM
+authentication, so the credential is your own gcloud login and nothing is
+stored.
+
+ gcloud auth application-default login
+ uv run --group ingestion python -m \\
+ automated_ingestion.scripts.report_duplicate_observations \\
+ --instance waterdatainitiative-271000:us-west4:dataservices \\
+ --database ocotillo-staging
+
+You need a database login. Being a project owner is not enough -- Cloud SQL
+requires the principal to exist as a database user:
+
+ gcloud sql users create YOUR_EMAIL --instance=dataservices \\
+ --type=cloud_iam_user --project=waterdatainitiative-271000
+
+Read-only. It counts and reports; deciding what to do about duplicates is a
+judgement about the data, not something a script should make.
+"""
+
+import argparse
+import sys
+
+DUPLICATE_GROUPS = """
+SELECT deployment_id, parameter_id, observation_datetime,
+ count(*) AS copies, count(DISTINCT value) AS distinct_values
+FROM transducer_observation
+GROUP BY deployment_id, parameter_id, observation_datetime
+HAVING count(*) > 1
+ORDER BY count(*) DESC, observation_datetime
+LIMIT 20
+"""
+
+TOTALS = """
+SELECT count(*) AS duplicate_groups,
+ coalesce(sum(copies) - count(*), 0) AS rows_above_the_first,
+ count(*) FILTER (WHERE distinct_values > 1) AS groups_that_disagree
+FROM (
+ SELECT count(*) AS copies, count(DISTINCT value) AS distinct_values
+ FROM transducer_observation
+ GROUP BY deployment_id, parameter_id, observation_datetime
+ HAVING count(*) > 1
+) g
+"""
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--instance", required=True, help="PROJECT:REGION:INSTANCE")
+ parser.add_argument("--database", required=True, help="e.g. ocotillo-staging")
+ parser.add_argument("--user", help="IAM principal; defaults to your gcloud account")
+ args = parser.parse_args()
+
+ user = args.user or _current_account()
+ if not user:
+ print("Could not determine your gcloud account; pass --user.", file=sys.stderr)
+ return 2
+
+ from google.cloud.sql.connector import Connector
+
+ connector = Connector()
+ try:
+ conn = connector.connect(
+ args.instance,
+ "pg8000",
+ user=user,
+ db=args.database,
+ enable_iam_auth=True,
+ )
+ except Exception as exc: # noqa: BLE001 - the message is the useful part
+ print(f"Could not connect as {user}: {exc}", file=sys.stderr)
+ print(
+ "\nIf this is a permissions error, the principal probably has no "
+ "database user:\n"
+ f" gcloud sql users create {user} --instance="
+ f"{args.instance.split(':')[-1]} --type=cloud_iam_user",
+ file=sys.stderr,
+ )
+ return 1
+
+ try:
+ cursor = conn.cursor()
+ cursor.execute(TOTALS)
+ groups, extra_rows, disagreeing = cursor.fetchone()
+
+ print(f"Database: {args.database}")
+ print(f" duplicate groups : {groups}")
+ print(f" rows above the first : {extra_rows}")
+ print(f" groups that disagree : {disagreeing}")
+
+ if not groups:
+ print("\nNo duplicates. The unique constraint migration is safe to run.")
+ return 0
+
+ print(
+ "\nThe migration will FAIL until these are resolved.\n"
+ "Groups that disagree are the ones to look at first: those rows hold "
+ "different values for the same instant, so they are conflicting "
+ "measurements rather than redundant copies, and collapsing them "
+ "discards a reading somebody recorded."
+ )
+ cursor.execute(DUPLICATE_GROUPS)
+ print("\n deployment parameter observed copies values")
+ for dep, param, observed, copies, values in cursor.fetchall():
+ print(
+ f" {dep:>10} {param:>9} {str(observed):<24} {copies:>6} {values:>6}"
+ )
+ return 1
+ finally:
+ conn.close()
+ connector.close()
+
+
+def _current_account() -> str | None:
+ import subprocess
+
+ try:
+ result = subprocess.run(
+ ["gcloud", "config", "get-value", "account"],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ except Exception: # noqa: BLE001
+ return None
+ account = result.stdout.strip()
+ return account or None
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+
+# ============= EOF =============================================
From 78bc921627fbad2ad546c84aa7a1a375d66cba4e Mon Sep 17 00:00:00 2001
From: jakeross
Date: Tue, 18 Aug 2026 23:20:48 -0700
Subject: [PATCH 094/151] fix(ingestion): write the raw zone as parquet
The first live materialization landed .jsonl.gz. dlt's filesystem destination
writes gzipped JSONL unless given a loader_file_format, and it never was --
while the plan, the source doc, and Mode B replay all assume parquet.
Replay reads the raw zone filtered on event time. A columnar format with real
types lets it read a window without decompressing and parsing every record, and
it round-trips the difference between a null and a missing field more reliably
than JSONL. pyarrow was already a dependency, added for this.
Objects already written stay JSONL. dlt reads both, so they need no migration,
but a replay spanning the boundary reads two formats -- noted in the source doc
rather than left to be discovered.
Co-Authored-By: Claude Opus 5
---
.../sources/san_acacia/dlt_pipeline.py | 11 +++++++++++
automated_ingestion/sources/san_acacia/ingest.py | 9 +++++++--
docs/sources/san_acacia.md | 16 ++++++++++++++++
3 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/automated_ingestion/sources/san_acacia/dlt_pipeline.py b/automated_ingestion/sources/san_acacia/dlt_pipeline.py
index 4c64088f4..465c8729f 100644
--- a/automated_ingestion/sources/san_acacia/dlt_pipeline.py
+++ b/automated_ingestion/sources/san_acacia/dlt_pipeline.py
@@ -59,6 +59,17 @@
than SO-0125.
"""
+LOADER_FILE_FORMAT = "parquet"
+"""Raw-zone file format.
+
+dlt writes gzipped JSONL unless told otherwise, and the first live run landed
+that way. Parquet is what Mode B replay assumes: replay reads the raw zone
+filtered on event time, and a columnar format with real types lets that read a
+window without decompressing and parsing every record. It also preserves the
+distinction between a null and a missing field, which JSONL round-trips less
+reliably.
+"""
+
INITIAL_START = "2015-01-01T00:00:00+00:00"
"""Floor for a point that has never been ingested.
diff --git a/automated_ingestion/sources/san_acacia/ingest.py b/automated_ingestion/sources/san_acacia/ingest.py
index 4d7d8a021..ff318d9fc 100644
--- a/automated_ingestion/sources/san_acacia/ingest.py
+++ b/automated_ingestion/sources/san_acacia/ingest.py
@@ -43,6 +43,7 @@ def _client() -> DiverHubClient:
def raw_san_acacia_locations(context: AssetExecutionContext) -> Output[int]:
"""Land the point roster in the raw zone."""
from automated_ingestion.sources.san_acacia.dlt_pipeline import (
+ LOADER_FILE_FORMAT,
PROJECT_ID,
build_pipeline,
vanessen_locations,
@@ -51,7 +52,7 @@ def raw_san_acacia_locations(context: AssetExecutionContext) -> Output[int]:
client = _client()
points = list(client.monitoring_points(PROJECT_ID))
pipeline = build_pipeline()
- pipeline.run(vanessen_locations(client))
+ pipeline.run(vanessen_locations(client), loader_file_format=LOADER_FILE_FORMAT)
context.log.info("landed %s monitoring points", len(points))
return Output(
@@ -72,6 +73,7 @@ def raw_san_acacia_locations(context: AssetExecutionContext) -> Output[int]:
def raw_san_acacia_readings(context: AssetExecutionContext) -> Output[int]:
"""Land water levels for every point, isolating per-point failure."""
from automated_ingestion.sources.san_acacia.dlt_pipeline import (
+ LOADER_FILE_FORMAT,
PROJECT_ID,
build_pipeline,
vanessen_readings,
@@ -86,7 +88,10 @@ def raw_san_acacia_readings(context: AssetExecutionContext) -> Output[int]:
pipeline = build_pipeline()
failures: list[dict[str, Any]] = []
- info = pipeline.run(vanessen_readings(client, points, end, failures))
+ info = pipeline.run(
+ vanessen_readings(client, points, end, failures),
+ loader_file_format=LOADER_FILE_FORMAT,
+ )
rows = _row_count(info)
if failures:
diff --git a/docs/sources/san_acacia.md b/docs/sources/san_acacia.md
index 55725aa40..b184d5636 100644
--- a/docs/sources/san_acacia.md
+++ b/docs/sources/san_acacia.md
@@ -77,6 +77,22 @@ halve-on-500 recovery is **not** a routine path for `WaterLevels`. Do not
assume a 500 there means "too much data" without re-measuring; on `DiverData`
that assumption is provably wrong.
+## Raw zone format
+
+Parquet, date-partitioned:
+
+```
+raw_sanacaciareach/vanessen_readings/year=2026/month=08/day=19/..parquet
+```
+
+dlt writes gzipped JSONL unless told otherwise, and the first live run landed
+that way before this was set. Parquet is what Mode B replay assumes: replay
+reads the raw zone filtered on event time, and a columnar format with real types
+lets it read a window without decompressing and parsing every record.
+
+Objects written before this change are `.jsonl.gz`. dlt reads both, so they do
+not need migrating, but a replay spanning that boundary reads two formats.
+
## Field mapping
### Water levels — the ingested series
From c20e2e39795f161d6ae3405df3cc9f30e506e131 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 08:15:52 -0700
Subject: [PATCH 095/151] feat(ingestion): reconcile San Acacia points against
Ocotillo wells
Task 3.2's first half: a report saying, per monitoring point, whether a matching
well exists. Read-only on both sides.
Also corrects the "33 wells" figure that has run through this plan from the
start. It came from Aqueduct's docs/sources/san_acacia.md, in a sentence about
`/locations/{projectName}` -- an endpoint that does not exist. That same
document supplied the doubled /api/api/ path, the claim the source is
unauthenticated, and the gs/vrd payload shape, all disproved against the live
API. The count has no more standing than the rest of it, so 38 is not a
discrepancy to explain but the number to use, and this was never the blocker it
was treated as.
Coordinate proximity, the third matching signal the plan called for, is not
available: MonitoringPoint is {id, name}. That removes the only fuzzy signal and
leaves two exact ones, so every match is defensible rather than probabilistic.
The module never picks a winner. More than one candidate is ambiguous and
escalates; none is unmatched and escalates. Ingestion does not create wells, and
choosing between two plausible ones is exactly the judgement that must not be
automated -- the duplicate Geographic Area groups in this database are the
standing reminder.
Names compare on significant characters, so SO-0125, so 0125 and SO0125 are one
identifier while SO-0126 stays a different well. report.ready is false unless
every point resolved, and false for empty input, because a partial load produces
a series that looks complete and is not.
Co-Authored-By: Claude Opus 5
---
.../scripts/reconcile_san_acacia.py | 121 +++++++++++
.../sources/san_acacia/reconcile.py | 196 ++++++++++++++++++
automated_ingestion/tests/test_reconcile.py | 136 ++++++++++++
docs/automated-ingestion-pipeline-plan.md | 39 ++--
docs/sources/san_acacia.md | 24 ++-
5 files changed, 494 insertions(+), 22 deletions(-)
create mode 100644 automated_ingestion/scripts/reconcile_san_acacia.py
create mode 100644 automated_ingestion/sources/san_acacia/reconcile.py
create mode 100644 automated_ingestion/tests/test_reconcile.py
diff --git a/automated_ingestion/scripts/reconcile_san_acacia.py b/automated_ingestion/scripts/reconcile_san_acacia.py
new file mode 100644
index 000000000..2e730ef2d
--- /dev/null
+++ b/automated_ingestion/scripts/reconcile_san_acacia.py
@@ -0,0 +1,121 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Produce the San Acacia reconciliation report.
+
+Task 3.2 calls for this **before** anything is written: for each monitoring
+point Diver-HUB returns, whether a matching Ocotillo well exists. Read-only on
+both sides -- it fetches the vendor's point list and queries `thing`, and
+changes nothing.
+
+ export DIVERHUB_USERNAME=... DIVERHUB_PASSWORD=...
+ uv run --group ingestion python -m \\
+ automated_ingestion.scripts.reconcile_san_acacia
+
+Exits non-zero when any point needs a human, so it can gate a later step
+without anyone having to read the output carefully.
+"""
+
+import sys
+
+from automated_ingestion.sources.san_acacia.reconcile import (
+ ThingCandidate,
+ VendorPoint,
+ format_report,
+ reconcile,
+)
+
+
+def _vendor_points() -> list[VendorPoint]:
+ import requests
+
+ from automated_ingestion.sources.san_acacia.client import DiverHubClient
+ from automated_ingestion.sources.san_acacia.dlt_pipeline import PROJECT_ID
+
+ client = DiverHubClient(requests.Session())
+ return [
+ VendorPoint(monitoring_point_id=p["id"], name=p["name"])
+ for p in client.monitoring_points(PROJECT_ID)
+ ]
+
+
+def _candidates(prefix: str) -> list[ThingCandidate]:
+ """Wells that could plausibly be San Acacia points.
+
+ Narrowed by name prefix rather than loading every well: the point ids are
+ `SO-####`, and comparing 38 names against the whole inventory would surface
+ coincidental matches from other prefixes without adding a real one.
+ """
+ from sqlalchemy import select
+
+ from db.engine import session_ctx
+ from db.thing import Thing
+ from db.thing_id_link import ThingIDLink
+
+ with session_ctx() as session:
+ things = session.execute(
+ select(Thing.id, Thing.name).where(Thing.name.ilike(f"{prefix}%"))
+ ).all()
+ links = session.execute(
+ select(ThingIDLink.thing_id, ThingIDLink.alternate_id)
+ ).all()
+
+ by_thing: dict[int, list[str]] = {}
+ for thing_id, alternate_id in links:
+ if alternate_id:
+ by_thing.setdefault(thing_id, []).append(alternate_id)
+
+ return [
+ ThingCandidate(
+ thing_id=thing_id,
+ name=name,
+ external_ids=tuple(by_thing.get(thing_id, ())),
+ )
+ for thing_id, name in things
+ ]
+
+
+def main() -> int:
+ import argparse
+
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--prefix",
+ default="SO-",
+ help="Well name prefix to consider as candidates (default: SO-).",
+ )
+ args = parser.parse_args()
+
+ try:
+ points = _vendor_points()
+ except Exception as exc: # noqa: BLE001 - the message is the useful part
+ print(f"Could not list monitoring points: {exc}", file=sys.stderr)
+ return 2
+
+ candidates = _candidates(args.prefix)
+ print(f"Vendor points from Diver-HUB : {len(points)}")
+ print(f"Ocotillo wells named {args.prefix}* : {len(candidates)}\n")
+
+ report = reconcile(points, candidates)
+ print(format_report(report))
+ return 0 if report.ready else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/sources/san_acacia/reconcile.py b/automated_ingestion/sources/san_acacia/reconcile.py
new file mode 100644
index 000000000..e7726dfd1
--- /dev/null
+++ b/automated_ingestion/sources/san_acacia/reconcile.py
@@ -0,0 +1,196 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Matching Diver-HUB monitoring points to Ocotillo wells.
+
+Ingestion never creates a well. A vendor point that matches nothing is a
+question for a person, not a row to invent -- the duplicate Geographic Area
+groups elsewhere in this database are the standing reminder that "looks like a
+new record" is not proof.
+
+So this decides, per point, one of three things: exactly one candidate
+(matched), more than one (ambiguous, escalate), or none (unmatched, escalate).
+It never picks a winner among candidates. Choosing between two plausible wells
+is precisely the judgement that should not be automated.
+
+**Matching is on identifiers only.** The plan called for coordinate proximity as
+a third signal; the live ``MonitoringPoint`` payload is ``{id, name}`` and
+carries no coordinates, so there is nothing to compare. That removes the one
+fuzzy signal and leaves two exact ones, which is a better position to be in --
+every match here is defensible rather than probabilistic.
+
+The functions are pure: they take vendor points and candidate rows and return a
+report. Loading the candidates is the caller's job, so the decision logic is
+testable without a database.
+"""
+
+from collections.abc import Iterable
+from dataclasses import dataclass, field
+from enum import Enum
+
+
+class MatchKind(str, Enum):
+ """How a point was matched, or why it was not."""
+
+ NAME = "matched-by-name"
+ EXTERNAL_ID = "matched-by-external-id"
+ AMBIGUOUS = "ambiguous"
+ UNMATCHED = "unmatched"
+
+
+@dataclass(frozen=True)
+class VendorPoint:
+ """A monitoring point as Diver-HUB reports it."""
+
+ monitoring_point_id: int
+ name: str
+
+
+@dataclass(frozen=True)
+class ThingCandidate:
+ """An Ocotillo well that might be the same well."""
+
+ thing_id: int
+ name: str
+ external_ids: tuple[str, ...] = ()
+
+
+@dataclass(frozen=True)
+class Match:
+ """What was decided about one vendor point."""
+
+ point: VendorPoint
+ kind: MatchKind
+ thing_id: int | None = None
+ candidates: tuple[int, ...] = ()
+
+ @property
+ def needs_a_human(self) -> bool:
+ return self.kind in (MatchKind.AMBIGUOUS, MatchKind.UNMATCHED)
+
+
+@dataclass
+class ReconciliationReport:
+ """The whole picture, for a person to read before anything is written."""
+
+ matches: list[Match] = field(default_factory=list)
+
+ @property
+ def matched(self) -> list[Match]:
+ return [m for m in self.matches if not m.needs_a_human]
+
+ @property
+ def ambiguous(self) -> list[Match]:
+ return [m for m in self.matches if m.kind is MatchKind.AMBIGUOUS]
+
+ @property
+ def unmatched(self) -> list[Match]:
+ return [m for m in self.matches if m.kind is MatchKind.UNMATCHED]
+
+ @property
+ def ready(self) -> bool:
+ """True when every point resolved to exactly one well.
+
+ Deliberately strict. A partial run that ingests the wells it recognised
+ and quietly skips the rest produces a series that looks complete and is
+ not.
+ """
+ return bool(self.matches) and not any(m.needs_a_human for m in self.matches)
+
+
+def _normalize(value: str) -> str:
+ """Reduce a well identifier to its significant characters.
+
+ Case, spacing and punctuation are dropped, so ``SO-0125``, ``so 0125`` and
+ ``SO0125`` compare equal -- one identifier written three ways.
+
+ This is still exact matching, not similarity: every significant character
+ must agree, so ``SO-0126`` remains a different well. The distinction matters
+ because a fuzzy matcher here would eventually merge two real wells, and the
+ whole point of this module is that it never chooses between candidates.
+ """
+ return "".join(c for c in (value or "") if c.isalnum()).upper()
+
+
+def match_point(point: VendorPoint, candidates: Iterable[ThingCandidate]) -> Match:
+ """Decide one point against the wells it might be."""
+ target = _normalize(point.name)
+
+ by_name = [c for c in candidates if _normalize(c.name) == target]
+ by_external = [
+ c
+ for c in candidates
+ if any(_normalize(x) == target for x in c.external_ids) and c not in by_name
+ ]
+
+ # Name first: it is the identifier the Bureau uses, and an external id link
+ # is a record of an association someone made, which may be older.
+ hits = by_name or by_external
+ kind = MatchKind.NAME if by_name else MatchKind.EXTERNAL_ID
+
+ if len(hits) == 1:
+ return Match(point=point, kind=kind, thing_id=hits[0].thing_id)
+ if len(hits) > 1:
+ return Match(
+ point=point,
+ kind=MatchKind.AMBIGUOUS,
+ candidates=tuple(c.thing_id for c in hits),
+ )
+ return Match(point=point, kind=MatchKind.UNMATCHED)
+
+
+def reconcile(
+ points: Iterable[VendorPoint], candidates: Iterable[ThingCandidate]
+) -> ReconciliationReport:
+ """Match every vendor point, reporting rather than resolving."""
+ candidate_list = list(candidates)
+ report = ReconciliationReport()
+ for point in points:
+ report.matches.append(match_point(point, candidate_list))
+ return report
+
+
+def format_report(report: ReconciliationReport) -> str:
+ """Human-readable summary. This is the deliverable of task 3.2."""
+ lines = [
+ f"Vendor points : {len(report.matches)}",
+ f" matched : {len(report.matched)}",
+ f" ambiguous : {len(report.ambiguous)}",
+ f" unmatched : {len(report.unmatched)}",
+ "",
+ ]
+ if report.ready:
+ lines.append("Every point resolved to exactly one well.")
+ return "\n".join(lines)
+
+ if report.ambiguous:
+ lines.append("Ambiguous -- more than one well matches. Do not auto-merge:")
+ for match in report.ambiguous:
+ ids = ", ".join(str(c) for c in match.candidates)
+ lines.append(f" {match.point.name:<12} thing ids: {ids}")
+ lines.append("")
+ if report.unmatched:
+ lines.append("Unmatched -- no well found. Ingestion will not create one:")
+ for match in report.unmatched:
+ lines.append(
+ f" {match.point.name:<12} (vendor id {match.point.monitoring_point_id})"
+ )
+ lines.append("")
+ lines.append("Resolve these before loading; a partial load looks complete.")
+ return "\n".join(lines)
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_reconcile.py b/automated_ingestion/tests/test_reconcile.py
new file mode 100644
index 000000000..9ea8b8b7e
--- /dev/null
+++ b/automated_ingestion/tests/test_reconcile.py
@@ -0,0 +1,136 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Reconciliation decisions.
+
+The rule that matters: never pick a winner among candidates. Ingestion does not
+create wells and must not choose between two plausible ones.
+"""
+
+from automated_ingestion.sources.san_acacia.reconcile import (
+ MatchKind,
+ ThingCandidate,
+ VendorPoint,
+ format_report,
+ match_point,
+ reconcile,
+)
+
+POINT = VendorPoint(monitoring_point_id=39, name="SO-0125")
+
+
+def test_exact_name_match():
+ match = match_point(POINT, [ThingCandidate(thing_id=7, name="SO-0125")])
+ assert match.kind is MatchKind.NAME
+ assert match.thing_id == 7
+ assert not match.needs_a_human
+
+
+def test_name_match_ignores_case_spacing_and_punctuation():
+ # One identifier written three ways. Still exact on significant characters.
+ for written in ("so 0125", "SO0125", " so-0125 "):
+ match = match_point(POINT, [ThingCandidate(thing_id=7, name=written)])
+ assert match.thing_id == 7, written
+
+
+def test_adjacent_identifier_is_not_a_match():
+ # Normalization must not become fuzziness: SO-0126 is a different well.
+ match = match_point(POINT, [ThingCandidate(thing_id=7, name="SO-0126")])
+ assert match.kind is MatchKind.UNMATCHED
+
+
+def test_external_id_match_when_the_name_differs():
+ match = match_point(
+ POINT,
+ [ThingCandidate(thing_id=9, name="Renamed Well", external_ids=("SO-0125",))],
+ )
+ assert match.kind is MatchKind.EXTERNAL_ID
+ assert match.thing_id == 9
+
+
+def test_name_wins_over_external_id():
+ # The name is the identifier the Bureau uses now; a link records an
+ # association someone made earlier, which may be stale.
+ match = match_point(
+ POINT,
+ [
+ ThingCandidate(thing_id=7, name="SO-0125"),
+ ThingCandidate(thing_id=9, name="Other", external_ids=("SO-0125",)),
+ ],
+ )
+ assert match.thing_id == 7
+
+
+def test_two_wells_with_the_same_name_are_ambiguous():
+ # Duplicate rows exist in this database. Picking one is exactly the
+ # judgement that must not be automated.
+ match = match_point(
+ POINT,
+ [
+ ThingCandidate(thing_id=7, name="SO-0125"),
+ ThingCandidate(thing_id=8, name="SO-0125"),
+ ],
+ )
+ assert match.kind is MatchKind.AMBIGUOUS
+ assert match.thing_id is None
+ assert match.candidates == (7, 8)
+ assert match.needs_a_human
+
+
+def test_no_candidate_is_unmatched_not_created():
+ match = match_point(POINT, [])
+ assert match.kind is MatchKind.UNMATCHED
+ assert match.thing_id is None
+
+
+class TestReport:
+ def _report(self):
+ points = [
+ VendorPoint(39, "SO-0125"),
+ VendorPoint(40, "SO-0131"),
+ VendorPoint(41, "SO-0140"),
+ ]
+ candidates = [
+ ThingCandidate(1, "SO-0125"),
+ ThingCandidate(2, "SO-0131"),
+ ThingCandidate(3, "SO-0131"),
+ ]
+ return reconcile(points, candidates)
+
+ def test_counts_split_by_outcome(self):
+ report = self._report()
+ assert len(report.matched) == 1
+ assert len(report.ambiguous) == 1
+ assert len(report.unmatched) == 1
+
+ def test_not_ready_while_anything_needs_a_human(self):
+ # A partial load produces a series that looks complete and is not.
+ assert self._report().ready is False
+
+ def test_ready_only_when_everything_resolves(self):
+ report = reconcile([VendorPoint(39, "SO-0125")], [ThingCandidate(1, "SO-0125")])
+ assert report.ready is True
+
+ def test_empty_input_is_not_ready(self):
+ # Nothing to reconcile is not the same as everything reconciled.
+ assert reconcile([], []).ready is False
+
+ def test_report_names_the_points_needing_attention(self):
+ text = format_report(self._report())
+ assert "SO-0131" in text and "SO-0140" in text
+
+
+# ============= EOF =============================================
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index e07edd73b..7bb9e1ced 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -4,7 +4,7 @@
## TL;DR
-Build the Bureau's first automated data ingestion pipeline, in the OcotilloAPI repo, so continuous depth-to-groundwater readings reach Ocotillo on a schedule instead of by hand. San Acacia Reach (33 Van Essen divers) is the pilot source; the structure it establishes is what every later source inherits.
+Build the Bureau's first automated data ingestion pipeline, in the OcotilloAPI repo, so continuous depth-to-groundwater readings reach Ocotillo on a schedule instead of by hand. San Acacia Reach (38 Van Essen divers) is the pilot source; the structure it establishes is what every later source inherits.
Stack: **Dagster+** code location → **dlt** extraction → **GCS** raw parquet → **`domain/`** mapping → direct **Postgres** load. Watermark and backfill mechanics are ported from Aqueduct, with two deliberate improvements a relational destination allows: the watermark is read from Postgres rather than a GCS sidecar, and an upsert replaces Aqueduct's delete-then-repost (removing its known window where data goes temporarily missing).
@@ -30,11 +30,11 @@ Stack: **Dagster+** code location → **dlt** extraction → **GCS** raw parquet
| 1.4 | DB connectivity + least-privilege role | Cloud SQL connector from serverless; scoped Postgres role | 1.2 |
| **T2** | **Source extraction** | Van Essen API → GCS raw zone | T1 |
| 2.1 | Confirm endpoint + finalize mapping | **Unblocked.** Diver-HUB swagger, JWT login, measure the window ceiling | — |
-| 2.2 | dlt resource: locations | 33 wells, `replace`, one call, no pagination | 1.3 |
+| 2.2 | dlt resource: locations | 38 wells, `replace`, one call, no pagination | 1.3 |
| 2.3 | dlt resource: readings, incremental | Windowed per-point fetch, dlt cursor, `append`, token refresh, failure isolation | 2.1 |
| **T3** | **Domain mapping + load** | Van Essen records → Ocotillo Postgres | T1 |
| 3.1 | Domain layer | Pure functions: units, datum, timestamps, geometry, external keys | — |
-| 3.2 | Bootstrap reference data | Reconcile 33 wells; seed parameter, sensor, deployments | 3.1 |
+| 3.2 | Bootstrap reference data | Reconcile 38 wells; seed parameter, sensor, deployments | 3.1 |
| 3.3 | Represent "public but provisional" | **Schema change.** `release_status` can't hold both axes | — |
| 3.4 | Unique constraint + upsert loader | **Schema change.** `ON CONFLICT DO UPDATE`; makes backfill idempotent | 3.2, 3.3 |
| 3.5 | Watermark from Postgres | `MAX(observation_datetime)` per series; no GCS sidecar | 3.4 |
@@ -51,11 +51,11 @@ Stack: **Dagster+** code location → **dlt** extraction → **GCS** raw parquet
**Goal:** continuous depth-to-groundwater data lands in Ocotillo automatically, on a schedule, with no one hand-carrying files — starting with San Acacia Reach.
-The Hydrograph Corrector UI exists and works (BDMS-1137 done), but has no automatic supply of raw data. San Acacia Reach's 33 Van Essen divers historically flowed through the retired FROST/`st2` stack and now flow nowhere. This epic builds the supply. Correction, review, and publication workflows are **out of scope** and belong to their own epic.
+The Hydrograph Corrector UI exists and works (BDMS-1137 done), but has no automatic supply of raw data. San Acacia Reach's 38 Van Essen divers historically flowed through the retired FROST/`st2` stack and now flow nowhere. This epic builds the supply. Correction, review, and publication workflows are **out of scope** and belong to their own epic.
New top-level `automated_ingestion/` package in OcotilloAPI, deployed as its own Dagster+ code location in the existing `nmbgmr-data-services` org. dlt extracts the Van Essen API to a GCS raw zone; a `domain/` layer maps to the Ocotillo model; a loader writes to Ocotillo Postgres over a direct DB connection. Watermark and backfill mechanics come from Aqueduct.
-San Acacia first: 33 wells, one DTW series each, and already mapped in `Aqueduct/docs/sources/san_acacia.md`. It authenticates with a short-lived JWT and must be read in bounded time windows — both cheap enough here to establish the pattern before a harder source needs it. What it establishes — source registry, per-source dlt pipeline, adapter, backfill job factory — every later source inherits.
+San Acacia first: 38 wells, one DTW series each, and already mapped in `Aqueduct/docs/sources/san_acacia.md`. It authenticates with a short-lived JWT and must be read in bounded time windows — both cheap enough here to establish the pattern before a harder source needs it. What it establishes — source registry, per-source dlt pipeline, adapter, backfill job factory — every later source inherits.
**Ownership: OcotilloAPI.** Not a third Aqueduct source writing into Ocotillo. The loader writes over a direct database connection, which wants the `db/` SQLAlchemy models and `domain/` rules in-process rather than a duplicated schema in another repo. Aqueduct stays the FROST/SensorThings pipeline; this is Ocotillo's own. The two share code by porting (see below), not by importing.
@@ -89,7 +89,7 @@ San Acacia first: 33 wells, one DTW series each, and already mapped in `Aqueduct
- Scheduled job runs end to end: Van Essen API → GCS parquet → domain mapping → Ocotillo Postgres.
- Re-running over an already-loaded window: zero duplicates, zero errors.
- Both backfill jobs exist, default `dry_run: true`, chunk by month, resume from last completed chunk.
-- 33 wells resolve to `Thing` records — matched or created, no duplicates.
+- 38 wells resolve to `Thing` records — matched, never created, no duplicates.
- Readings are public, marked provisional, stored as DTW below ground surface in feet.
- Series render in the Hydrograph Corrector.
- Domain mapping unit-tested with no database, per `ADR4.md`.
@@ -178,7 +178,7 @@ Dagster+ Serverless is outside the VPC, so Cloud SQL's private IP is unreachable
- ✅ Role DDL in `automated_ingestion/sql/ingestion_role.sql`, kept out of Alembic: roles and grants are per-environment infrastructure, not schema, and migrations do not run as a superuser.
- ⬜ Run the DDL per environment; set `DB_DRIVER`, `CLOUD_SQL_*` on the code location; materialize the asset from both a branch and prod deployment.
-**The grant list is narrower than the draft assumed, and one part of it is non-obvious.** Writable: `transducer_observation`, `transducer_observation_block`, `deployment`, `sensor`, `parameter`. Read-only: `thing`, `thing_id_link`, `location`, and the three `lexicon_*` tables — `thing` and `location` deliberately *not* writable, because reconciling the 33 wells means matching rows that already exist. A well found missing is a decision for a human, not a row the pipeline invents.
+**The grant list is narrower than the draft assumed, and one part of it is non-obvious.** Writable: `transducer_observation`, `transducer_observation_block`, `deployment`, `sensor`, `parameter`. Read-only: `thing`, `thing_id_link`, `location`, and the three `lexicon_*` tables — `thing` and `location` deliberately *not* writable, because reconciling the 38 wells means matching rows that already exist. A well found missing is a decision for a human, not a row the pipeline invents.
`parameter` is versioned by sqlalchemy-continuum, so inserting one also writes to `parameter_version` and `transaction`. Without those two grants the write fails on a table the code never names — the kind of error that costs an afternoon. (`transducer_observation` itself is not versioned; only `aquifer_system`, `geologic_formation`, `location`, `observation`, `parameter`, `regulatory_limit`, and `thing` are.) Sequence `USAGE` is granted explicitly, and no default privileges are set: a table added later stays invisible until someone grants it deliberately.
@@ -219,7 +219,7 @@ Also still open: the window ceiling (three months works, the limit is unmeasured
- ✅ Asset `raw_san_acacia_locations` emits the point count, project id, and a sample of names. Tested against a stub, no network.
- ✅ `replace` rather than `append`: this is a snapshot of what the vendor currently lists, and a point disappearing is information rather than something to accumulate.
-The payload is `{id, name}` only, so this cannot be a source of geometry or construction detail — it enumerates the points a reading fetch walks. **38 points, not the 33 the plan assumes**, still unexplained.
+The payload is `{id, name}` only, so this cannot be a source of geometry or construction detail — it enumerates the points a reading fetch walks. **38 points.** Earlier drafts said 33; that figure came from Aqueduct's stale mapping doc, not from a Bureau record — see 3.2.
### 2.3 — dlt resource: readings → GCS, incremental
@@ -237,9 +237,9 @@ The payload is `{id, name}` only, so this cannot be a source of geometry or cons
# TASK 3 — Domain mapping and load into Ocotillo
-Where this stops resembling Aqueduct: the destination is a relational database with constraints and transactions, and mapping rules belong in `domain/` per `ADR4.md`. Three risks — matching 33 wells without duplicating them, representing "public but provisional" when the schema can't, and making the write idempotent so backfill is safe.
+Where this stops resembling Aqueduct: the destination is a relational database with constraints and transactions, and mapping rules belong in `domain/` per `ADR4.md`. Three risks — matching 38 wells without duplicating them, representing "public but provisional" when the schema can't, and making the write idempotent so backfill is safe.
-**Done when:** mapping rules are pure functions tested without a database; 33 wells resolve with no duplicates; data is public and separately marked provisional; `transducer_observation` has a unique constraint and the loader upserts against it; loading the same window twice leaves the row count unchanged; the watermark comes from Postgres.
+**Done when:** mapping rules are pure functions tested without a database; 38 wells resolve with no duplicates; data is public and separately marked provisional; `transducer_observation` has a unique constraint and the loader upserts against it; loading the same window twice leaves the row count unchanged; the watermark comes from Postgres.
### 3.1 — Domain layer: Van Essen record → Ocotillo model
@@ -266,14 +266,19 @@ The module docstring lists every value the mapping **invents** rather than reads
### 3.2 — Bootstrap reference data: reconcile wells, seed parameter, sensor, deployments
-Some of the 33 may already exist in Ocotillo under Bureau point IDs. Duplicates are the main risk — the `group_type` collision elsewhere in this database is the reminder that "looks new" isn't proof.
+Reconciliation report built — `sources/san_acacia/reconcile.py` and `scripts/reconcile_san_acacia.py`, 12 tests. The seeding half is not built.
-- Reconciliation report **first**: per well, whether a matching `Thing` exists — on name, on `monitoringPoints[].name` (e.g. `SO-0125`), and on coordinate proximity. Ambiguous matches escalate to a human, never auto-merge.
-- Data migration (existing `data_migrations/` runner, already supports dry-run) creates missing `Location`/`Thing`, links existing ones. Idempotent, dry-run-clean before running for real.
-- Lexicon terms, a DTW `Parameter`, and a `VanEssenDiver` `Sensor` created if absent.
-- One `Deployment` per well (thing → sensor), `recording_interval` ~5 min where known.
-- Van Essen `uid` (e.g. `sanacaciareach-40`) persisted as external identifier.
-- `DataProvenance` recorded for Van Essen-sourced well attributes: depth, coordinates, installation date.
+**The "33 wells" figure was wrong, and was never a blocker.** It came from Aqueduct's `docs/sources/san_acacia.md` — the same document that also supplied the doubled `/api/api/` path, the claim the source is unauthenticated, and the `gs`/`vrd` payload shape, all disproved against the live API. 38 is what the API returns. Whether all 38 are in scope is a question the per-well report answers concretely.
+
+**Coordinate proximity is not available.** This section called for matching on name, external id, *and* coordinate proximity. `MonitoringPoint` is `{id, name}` — no coordinates. That removes the only fuzzy signal and leaves two exact ones, which is a better position: every match is defensible rather than probabilistic.
+
+- ✅ Matching on name and on `thing_id_link.alternate_id`, normalized for case, spacing and punctuation so `SO-0125`, `so 0125` and `SO0125` compare equal. Still exact on significant characters — `SO-0126` stays a different well.
+- ✅ Name beats external id when both hit. The name is the identifier the Bureau uses now; a link records an association someone made earlier.
+- ✅ **Never picks a winner.** More than one candidate is `ambiguous` and escalates; none is `unmatched` and escalates. Ingestion does not create wells, and choosing between two plausible ones is the judgement that must not be automated.
+- ✅ `report.ready` is false unless *every* point resolved, and false for empty input. A partial load produces a series that looks complete and is not.
+- ✅ The script exits non-zero when anything needs a human, so it can gate a later step without relying on someone reading the output.
+- ⬜ Run it against staging and production and act on the result.
+- ⬜ The seeding half: data migration creating missing `Location`/`Thing`, lexicon terms, DTW `Parameter`, `VanEssenDiver` `Sensor`, one `Deployment` per well, the vendor `uid` as external identifier, and `DataProvenance` for Van Essen-sourced attributes.
### 3.3 — Represent "public but provisional"
diff --git a/docs/sources/san_acacia.md b/docs/sources/san_acacia.md
index b184d5636..787670ba2 100644
--- a/docs/sources/san_acacia.md
+++ b/docs/sources/san_acacia.md
@@ -1,10 +1,24 @@
# Source: San Acacia Reach (Van Essen divers, Diver-HUB)
The pilot source for automated ingestion. Project **4317 `SanAcaciaReach`**,
-containing **38 monitoring points** named `SO-####` — the plan and the Aqueduct
-mapping both say 33, so five are unaccounted for and must be identified before
-3.2 reconciles anything. Ingestion never creates wells, so an unexpected point
-is a decision, not a row. Historically flowed through the retired
+containing **38 monitoring points** named `SO-####`.
+
+**On the "33 wells" figure.** Earlier drafts of the plan said 33 and treated 38
+as a discrepancy to resolve. It is not one. The number came from Aqueduct's
+`docs/sources/san_acacia.md`, in a sentence describing an endpoint that no
+longer exists:
+
+> Pagination: none — `/locations/{projectName}` returns all 33 wells in one response.
+
+That document is also where the doubled `/api/api/` path, the claim that the
+source is unauthenticated, and the `gs`/`vrd` array payload came from — all four
+disproved against the live API. The count has no more standing than the rest of
+it: a FROST-era snapshot, not a Bureau record of how many wells the reach has.
+
+**38 is the live count.** Whether all 38 are in scope — some may be
+decommissioned, or belong to a neighbouring project — is a question about the
+well inventory, and the reconciliation report answers it concretely, per well,
+rather than by arguing about a total. Historically flowed through the retired
FROST/`st2` stack; now flows nowhere.
This document supersedes the mapping in `Aqueduct/docs/sources/san_acacia.md`,
@@ -203,7 +217,7 @@ Settled, not to be relitigated per source:
|---|---|---|
| 1 | ~~Which `reference` value is ground surface?~~ | **Answered: 3.** Corroboration via `ManualMeasurements` still outstanding |
| 2 | ~~What is the window ceiling?~~ | **`WaterLevels` took 730 d / 18111 rows. The 500 is a `DiverData` problem** |
-| 3 | ~~Which project id, how many points?~~ | **Answered: 4317, 38 points (not 33)** |
+| 3 | ~~Which project id, how many points?~~ | **Answered: 4317, 38 points. The 33 was a stale figure, not a discrepancy** |
| 4 | Do `approved=true` and `approved=false` partition the series, or overlap? | Fetch both for one window and compare timestamps |
| 5 | Is `dateAndTime` UTC in the response, and is it marked as such? | Inspect a live payload |
| 6 | ~~Is `level` in feet?~~ | **No — centimetres.** Convert with `convert_cm_to_ft` |
From 50963f4e42f275dd9aafd4b041a6cc8701aafcec Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 08:35:26 -0700
Subject: [PATCH 096/151] fix(ingestion): import ThingIdLink from where it
actually lives
I inferred the module path from the table name: thing_id_link became
db.thing_id_link, and the class ThingIDLink. Both wrong -- the class is
ThingIdLink and it is defined in db.thing.
The query is now exercised against a real database rather than only imported, so
the SQL is verified and not just the syntax. Every import in both new modules is
checked to resolve, which is the class of mistake this was.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/scripts/reconcile_san_acacia.py | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/automated_ingestion/scripts/reconcile_san_acacia.py b/automated_ingestion/scripts/reconcile_san_acacia.py
index 2e730ef2d..973cafbd6 100644
--- a/automated_ingestion/scripts/reconcile_san_acacia.py
+++ b/automated_ingestion/scripts/reconcile_san_acacia.py
@@ -62,15 +62,14 @@ def _candidates(prefix: str) -> list[ThingCandidate]:
from sqlalchemy import select
from db.engine import session_ctx
- from db.thing import Thing
- from db.thing_id_link import ThingIDLink
+ from db.thing import Thing, ThingIdLink
with session_ctx() as session:
things = session.execute(
select(Thing.id, Thing.name).where(Thing.name.ilike(f"{prefix}%"))
).all()
links = session.execute(
- select(ThingIDLink.thing_id, ThingIDLink.alternate_id)
+ select(ThingIdLink.thing_id, ThingIdLink.alternate_id)
).all()
by_thing: dict[int, list[str]] = {}
From d102cb7b4b760bdbc4beaa50ee75101995a88931 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 08:45:41 -0700
Subject: [PATCH 097/151] fix(ingestion): do not match on external ids by
default
Reconciling against staging answered 3.2: all 38 Diver-HUB points match Ocotillo
wells by name, nothing ambiguous, nothing unmatched. The wells already exist, so
the seeding half creates none.
The same data showed external-id matching is unsafe here. thing_id_link holds
11,148 links from nine organization/relation pairs that disagree with each
other. SO-0131 carries NMBGMR "BRN-E04B (shallow)" plus an unattributed
"BRN-E04A", while SO-0132 carries NMBGMR "BRN-E04A (deep)" plus an unattributed
"BRN-E04B" -- the two sources swap which physical well is A and which is B.
Matching BRN-E04A against that returns one confident hit on SO-0131,
contradicting NMBGMR, because the parenthetical suffix stops the collision
registering as ambiguous. That is worse than the ambiguity the module was built
to escalate: a wrong answer delivered with no sign of trouble.
So the fallback is opt-in, and a test pins those exact rows. It costs nothing
today, since every point matches by name.
Co-Authored-By: Claude Opus 5
---
.../sources/san_acacia/reconcile.py | 45 +++++++++++++++----
automated_ingestion/tests/test_reconcile.py | 34 +++++++++++++-
docs/automated-ingestion-pipeline-plan.md | 7 ++-
3 files changed, 75 insertions(+), 11 deletions(-)
diff --git a/automated_ingestion/sources/san_acacia/reconcile.py b/automated_ingestion/sources/san_acacia/reconcile.py
index e7726dfd1..9a555c866 100644
--- a/automated_ingestion/sources/san_acacia/reconcile.py
+++ b/automated_ingestion/sources/san_acacia/reconcile.py
@@ -125,16 +125,39 @@ def _normalize(value: str) -> str:
return "".join(c for c in (value or "") if c.isalnum()).upper()
-def match_point(point: VendorPoint, candidates: Iterable[ThingCandidate]) -> Match:
- """Decide one point against the wells it might be."""
+def match_point(
+ point: VendorPoint,
+ candidates: Iterable[ThingCandidate],
+ use_external_ids: bool = False,
+) -> Match:
+ """Decide one point against the wells it might be.
+
+ ``use_external_ids`` is off by default, for a specific reason.
+ ``thing_id_link`` holds identifiers from several organizations that disagree
+ with each other. In staging, ``SO-0131`` carries NMBGMR ``BRN-E04B
+ (shallow)`` plus an unattributed ``BRN-E04A``, while ``SO-0132`` carries
+ NMBGMR ``BRN-E04A (deep)`` plus an unattributed ``BRN-E04B`` -- the two
+ sources swap which physical well is A and which is B.
+
+ Matching ``BRN-E04A`` against that returns a single confident hit on
+ SO-0131, contradicting NMBGMR, because the parenthetical suffix stops the
+ collision registering as ambiguous. A wrong answer delivered confidently is
+ worse than no answer.
+
+ It costs nothing today: all 38 Diver-HUB points match Ocotillo wells by name.
+ """
target = _normalize(point.name)
by_name = [c for c in candidates if _normalize(c.name) == target]
- by_external = [
- c
- for c in candidates
- if any(_normalize(x) == target for x in c.external_ids) and c not in by_name
- ]
+ by_external = (
+ [
+ c
+ for c in candidates
+ if any(_normalize(x) == target for x in c.external_ids) and c not in by_name
+ ]
+ if use_external_ids
+ else []
+ )
# Name first: it is the identifier the Bureau uses, and an external id link
# is a record of an association someone made, which may be older.
@@ -153,13 +176,17 @@ def match_point(point: VendorPoint, candidates: Iterable[ThingCandidate]) -> Mat
def reconcile(
- points: Iterable[VendorPoint], candidates: Iterable[ThingCandidate]
+ points: Iterable[VendorPoint],
+ candidates: Iterable[ThingCandidate],
+ use_external_ids: bool = False,
) -> ReconciliationReport:
"""Match every vendor point, reporting rather than resolving."""
candidate_list = list(candidates)
report = ReconciliationReport()
for point in points:
- report.matches.append(match_point(point, candidate_list))
+ report.matches.append(
+ match_point(point, candidate_list, use_external_ids=use_external_ids)
+ )
return report
diff --git a/automated_ingestion/tests/test_reconcile.py b/automated_ingestion/tests/test_reconcile.py
index 9ea8b8b7e..3317d0bc8 100644
--- a/automated_ingestion/tests/test_reconcile.py
+++ b/automated_ingestion/tests/test_reconcile.py
@@ -52,16 +52,47 @@ def test_adjacent_identifier_is_not_a_match():
assert match.kind is MatchKind.UNMATCHED
-def test_external_id_match_when_the_name_differs():
+def test_external_ids_are_ignored_by_default():
match = match_point(
POINT,
[ThingCandidate(thing_id=9, name="Renamed Well", external_ids=("SO-0125",))],
)
+ assert match.kind is MatchKind.UNMATCHED
+
+
+def test_external_id_match_when_explicitly_enabled():
+ match = match_point(
+ POINT,
+ [ThingCandidate(thing_id=9, name="Renamed Well", external_ids=("SO-0125",))],
+ use_external_ids=True,
+ )
assert match.kind is MatchKind.EXTERNAL_ID
assert match.thing_id == 9
+def test_external_ids_can_produce_a_confident_wrong_answer():
+ """Why external id matching is off by default. Real rows from staging.
+
+ SO-0131 and SO-0132 swap which physical well is A and which is B between
+ NMBGMR and the unattributed source. Matching BRN-E04A returns SO-0131 with
+ no hint of trouble, while NMBGMR asserts SO-0132 is BRN-E04A -- the
+ parenthetical suffix stops the collision registering as ambiguous.
+ """
+ candidates = [
+ ThingCandidate(2369, "SO-0131", ("BRN-E04B (shallow)", "BRN-E04A")),
+ ThingCandidate(2373, "SO-0132", ("BRN-E04A (deep)", "BRN-E04B")),
+ ]
+ enabled = match_point(
+ VendorPoint(999, "BRN-E04A"), candidates, use_external_ids=True
+ )
+ assert enabled.thing_id == 2369 # contradicts NMBGMR, and looks certain
+
+ default = match_point(VendorPoint(999, "BRN-E04A"), candidates)
+ assert default.kind is MatchKind.UNMATCHED # escalates instead
+
+
def test_name_wins_over_external_id():
+ # Only relevant when external ids are enabled.
# The name is the identifier the Bureau uses now; a link records an
# association someone made earlier, which may be stale.
match = match_point(
@@ -70,6 +101,7 @@ def test_name_wins_over_external_id():
ThingCandidate(thing_id=7, name="SO-0125"),
ThingCandidate(thing_id=9, name="Other", external_ids=("SO-0125",)),
],
+ use_external_ids=True,
)
assert match.thing_id == 7
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index 7bb9e1ced..abad6bd2e 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -277,7 +277,12 @@ Reconciliation report built — `sources/san_acacia/reconcile.py` and `scripts/r
- ✅ **Never picks a winner.** More than one candidate is `ambiguous` and escalates; none is `unmatched` and escalates. Ingestion does not create wells, and choosing between two plausible ones is the judgement that must not be automated.
- ✅ `report.ready` is false unless *every* point resolved, and false for empty input. A partial load produces a series that looks complete and is not.
- ✅ The script exits non-zero when anything needs a human, so it can gate a later step without relying on someone reading the output.
-- ⬜ Run it against staging and production and act on the result.
+- ✅ **Run against staging: all 38 points match by name. Nothing ambiguous, nothing unmatched, `ready = True`.** The wells already exist — SO-0125 is thing 2343, SO-0131 is 2369, and so on through 277 `SO-` wells in that database. So the seeding half creates no wells; it only needs the parameter, sensor, deployments and external identifiers.
+- ⬜ Run against production and confirm the same.
+
+**External-id matching is off by default, on evidence.** `thing_id_link` in staging holds 11,148 links from nine organization/relation pairs — NMBGMR (8,603), PLSS (7,052), an unattributed "Unknown" (4,825), NMOSE, USGS, NMED, TWDB — and they disagree with each other. `SO-0131` carries NMBGMR `BRN-E04B (shallow)` plus an unattributed `BRN-E04A`, while `SO-0132` carries NMBGMR `BRN-E04A (deep)` plus an unattributed `BRN-E04B`: the two sources swap which physical well is A and which is B. (`SO-0262`/`SO-0263` disagree more sharply still — NMBGMR calls them NRCS 3A/3B, the other source NRCS 2.)
+
+Matching `BRN-E04A` against that returns a single confident hit on `SO-0131`, contradicting NMBGMR, because the parenthetical suffix stops the collision registering as ambiguous. A wrong answer delivered confidently is worse than no answer, so the fallback is opt-in and a test pins the real rows.
- ⬜ The seeding half: data migration creating missing `Location`/`Thing`, lexicon terms, DTW `Parameter`, `VanEssenDiver` `Sensor`, one `Deployment` per well, the vendor `uid` as external identifier, and `DataProvenance` for Van Essen-sourced attributes.
### 3.3 — Represent "public but provisional"
From 6d8cf475128ee69f8ad52ee60ec8bd25717ef7d2 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 08:55:25 -0700
Subject: [PATCH 098/151] docs(ingestion): confirm reconciliation against
production
All 38 Diver-HUB points match Ocotillo wells by name in production too, with the
same thing ids as staging -- SO-0125 is 2343 in both -- so the two agree by
construction and 3.2's reconciliation is settled for both environments. The
seeding half creates no wells.
Also records that the identifier contradictions are production data rather than
a staging artifact. SO-0131/SO-0132 and SO-0262/SO-0263 are paired shallow/deep
piezometers whose A/B designations disagree between identifier sources, which
matters to anyone reasoning about those wells through the BRN- or NRCS names
even though ingestion itself is unaffected.
Co-Authored-By: Claude Opus 5
---
docs/automated-ingestion-pipeline-plan.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index abad6bd2e..24201288d 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -278,11 +278,13 @@ Reconciliation report built — `sources/san_acacia/reconcile.py` and `scripts/r
- ✅ `report.ready` is false unless *every* point resolved, and false for empty input. A partial load produces a series that looks complete and is not.
- ✅ The script exits non-zero when anything needs a human, so it can gate a later step without relying on someone reading the output.
- ✅ **Run against staging: all 38 points match by name. Nothing ambiguous, nothing unmatched, `ready = True`.** The wells already exist — SO-0125 is thing 2343, SO-0131 is 2369, and so on through 277 `SO-` wells in that database. So the seeding half creates no wells; it only needs the parameter, sensor, deployments and external identifiers.
-- ⬜ Run against production and confirm the same.
+- ✅ **Production confirms it**: same 38 matches, same thing ids (SO-0125 is 2343 in both), `ready = True`. Staging is a clone of production for these tables, so the two agree by construction.
**External-id matching is off by default, on evidence.** `thing_id_link` in staging holds 11,148 links from nine organization/relation pairs — NMBGMR (8,603), PLSS (7,052), an unattributed "Unknown" (4,825), NMOSE, USGS, NMED, TWDB — and they disagree with each other. `SO-0131` carries NMBGMR `BRN-E04B (shallow)` plus an unattributed `BRN-E04A`, while `SO-0132` carries NMBGMR `BRN-E04A (deep)` plus an unattributed `BRN-E04B`: the two sources swap which physical well is A and which is B. (`SO-0262`/`SO-0263` disagree more sharply still — NMBGMR calls them NRCS 3A/3B, the other source NRCS 2.)
Matching `BRN-E04A` against that returns a single confident hit on `SO-0131`, contradicting NMBGMR, because the parenthetical suffix stops the collision registering as ambiguous. A wrong answer delivered confidently is worse than no answer, so the fallback is opt-in and a test pins the real rows.
+
+**This is production data, not a staging artifact.** The same contradictions are in both. They are worth someone's attention independently of this pipeline: `SO-0131`/`SO-0132` and `SO-0262`/`SO-0263` are paired shallow/deep piezometers whose A/B designations disagree between identifier sources, and a swap there means a shallow series attributed to a deep well. Ingestion is unaffected — the vendor names points `SO-####` and Ocotillo agrees on those — but anyone reasoning about these wells through the `BRN-`/`NRCS` names is working from two incompatible answers.
- ⬜ The seeding half: data migration creating missing `Location`/`Thing`, lexicon terms, DTW `Parameter`, `VanEssenDiver` `Sensor`, one `Deployment` per well, the vendor `uid` as external identifier, and `DataProvenance` for Van Essen-sourced attributes.
### 3.3 — Represent "public but provisional"
From e6de538aa7ce56ea24c46b3ec01b3bf0b0e96162 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 08:59:42 -0700
Subject: [PATCH 099/151] docs: record the San Acacia piezometer identifier
conflicts
Found while reconciling Diver-HUB points against Ocotillo wells. Present in
production, not only staging.
Three separate problems. The NMBGMR and unattributed identifier sources assign A
and B to opposite wells for the BRN-E04 shallow/deep pair. They disagree about
whether SO-0262/SO-0263 belong to NRCS site 3 or site 2, where site 2 already
exists as SO-0274 and its piezometers. And the A/B suffix does not consistently
indicate depth -- A is the deep well at four sites and the shallow one at two,
inconsistently even within the NRCS series.
None of it blocks ingestion, which matches on SO-#### where both systems agree.
It matters to anyone identifying these wells by field name, and a swap on a
paired piezometer nest attributes a shallow series to a deep well.
Co-Authored-By: Claude Opus 5
---
...-acacia-piezometer-identifier-conflicts.md | 77 +++++++++++++++++++
1 file changed, 77 insertions(+)
create mode 100644 docs/san-acacia-piezometer-identifier-conflicts.md
diff --git a/docs/san-acacia-piezometer-identifier-conflicts.md b/docs/san-acacia-piezometer-identifier-conflicts.md
new file mode 100644
index 000000000..29287abd7
--- /dev/null
+++ b/docs/san-acacia-piezometer-identifier-conflicts.md
@@ -0,0 +1,77 @@
+# San Acacia piezometer identifier conflicts
+
+Found 2026-08-19 while reconciling Diver-HUB monitoring points against Ocotillo
+wells for the automated ingestion pipeline. Present in **production**, not only
+staging.
+
+None of this blocks ingestion — the vendor names its points `SO-####` and
+Ocotillo agrees on those, so matching is exact. It matters to anyone who
+identifies these wells by their field names instead.
+
+## 1. Two identifier sources disagree about which well is which
+
+`thing_id_link` holds a `NMBGMR` identifier and an unattributed `Unknown` one
+for most `SO-` wells. For the BRN-E04 pair they contradict each other:
+
+| Well | NMBGMR | Unknown |
+|---|---|---|
+| `SO-0131` | `BRN-E04B (shallow)` | `BRN-E04A` |
+| `SO-0132` | `BRN-E04A (deep)` | `BRN-E04B` |
+
+The two sources assign A and B to opposite wells. Since these are a paired
+shallow/deep piezometer nest, resolving `BRN-E04A` to the wrong one attributes a
+shallow water-level series to a deep well or the reverse.
+
+`SO-0131` is one of the 38 points the ingestion pipeline reads.
+
+## 2. A site number disagreement, which may collide with a real site
+
+| Well | NMBGMR | Unknown |
+|---|---|---|
+| `SO-0262` | `NRCS 3A (Deep)` | `NRCS 2 (Deep)` |
+| `SO-0263` | `NRCS 3B (Shallow monitor well)` | `NRCS 2 (Shallow)` |
+
+NRCS site 2 exists separately — `SO-0274` is `NRCS Site 2 Well 2`, and
+`SO-0275`/`SO-0276` are its piezometers `2A`/`2B`. So the `Unknown` labels on
+`SO-0262`/`SO-0263` either duplicate a different site's number or the `NMBGMR`
+labels are wrong. One of the two is.
+
+## 3. The A/B suffix does not consistently mean depth
+
+Where NMBGMR annotates depth, the convention reverses between sites:
+
+| Site | A | B |
+|---|---|---|
+| `BRN-E04` | deep | shallow |
+| `HWY-W09` | deep | shallow |
+| `SBB-W02` | deep | shallow |
+| `NRCS 3` | deep | shallow |
+| `NRCS 4` | **shallow** | **deep** |
+| `NRCS 6` | **shallow** | **deep** |
+
+It is not even consistent within the NRCS series: site 3 has A as the deep well,
+sites 4 and 6 have A as the shallow one.
+
+So the suffix cannot be used to infer completion depth, and any code or analysis
+that assumes "A is the deep one" is right for four sites and wrong for two.
+
+## Why it was not caught earlier
+
+Nothing reads these identifiers programmatically today. The reconciler found it
+because matching on `alternate_id` produced a *confident* match for `BRN-E04A` —
+a single hit on `SO-0131`, contradicting NMBGMR, with no ambiguity flag, because
+the `(shallow)` suffix makes the two strings differ.
+
+External-id matching is now opt-in for that reason
+(`automated_ingestion/sources/san_acacia/reconcile.py`), with a test pinning
+these rows. That is a guard, not a fix: the underlying records still disagree.
+
+## What resolving it needs
+
+Someone with the field records or the drilling logs, deciding per pair which
+physical well is which. The depth annotations in the NMBGMR labels are the only
+in-database evidence, and for BRN-E04 they are exactly what is in dispute.
+
+Worth checking whether the `Unknown` organization rows have a determinable
+provenance — 4,825 links carry it, and if they came from a single import their
+reliability can be assessed as a group rather than well by well.
From 9dff7fb5a9282f14a182500d51320e606683ba44 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 09:13:08 -0700
Subject: [PATCH 100/151] feat(transducer): add data_maturity to observations
release_status is one column whose lexicon lists public and provisional as
siblings, so a reading could not be both visible and marked unreviewed. Those
are orthogonal questions -- who may see it, and how much it should be trusted --
and this adds the second axis.
A lexicon term rather than an is_provisional boolean: review is a progression,
not a switch, and a boolean cannot express the middle.
Terms follow USGS usage. provisional and approved are what USGS publishes
against; in review is the intermediate state from the Aquarius approval levels
used for continuous time series. Aquarius' Working is folded into provisional,
since to a consumer the two are indistinguishable.
Existing rows are left NULL rather than defaulted. Backfilling 88,666 legacy
observations to provisional would assert something about NMA data nobody has
checked -- some may be approved. NULL reads as not stated, which is true.
provisional and approved already existed as terms, since lexicon_term.term is
globally unique and categories share terms by association, so only "in review"
is new. approved is therefore shared with review_status; the two ask different
questions, and shared vocabulary is how this lexicon is built.
The loader defaults to provisional and refreshes maturity on upsert, so a
corrected reading arriving as approved does not keep the older maturity.
Co-Authored-By: Claude Opus 5
---
.../b2c3d4e5f6a7_transducer_data_maturity.py | 108 ++++++++++++++++++
automated_ingestion/ocotillo/loader.py | 17 ++-
core/enums.py | 1 +
core/lexicon.json | 17 ++-
db/transducer.py | 9 ++
docs/automated-ingestion-pipeline-plan.md | 23 +++-
schemas/group.py | 1 +
schemas/transducer.py | 6 +-
tests/test_transducer_loader.py | 76 ++++++++++++
9 files changed, 248 insertions(+), 10 deletions(-)
create mode 100644 alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py
diff --git a/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py b/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py
new file mode 100644
index 000000000..5b464b1d2
--- /dev/null
+++ b/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py
@@ -0,0 +1,108 @@
+"""data_maturity on transducer_observation
+
+Revision ID: b2c3d4e5f6a7
+Revises: a1b2c3d4e5f6
+Create Date: 2026-08-19
+
+`release_status` is one column whose lexicon lists `public` and `provisional` as
+siblings, so a reading cannot be both visible and marked unreviewed. Those are
+orthogonal: visibility is who may see it, maturity is how much it should be
+trusted. This adds the second axis.
+
+Terms follow USGS usage. `provisional` and `approved` are what USGS publishes
+against -- "provisional data subject to revision" is the standard caveat on
+unapproved records. `in review` is the intermediate state from the Aquarius
+approval levels USGS uses for continuous time series (Working / In Review /
+Approved); Aquarius' `Working` is folded into `provisional` because the two are
+indistinguishable to a consumer.
+
+**Existing rows are left NULL rather than defaulted.** Backfilling 88,000+
+observations to `provisional` would assert something about legacy NMA data that
+nobody has checked -- some of it may well be approved. NULL reads as "not
+stated", which is true.
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = "b2c3d4e5f6a7"
+down_revision = "a1b2c3d4e5f6"
+branch_labels = None
+depends_on = None
+
+CATEGORY = "data_maturity"
+TERMS = ("provisional", "in review", "approved")
+
+
+def upgrade() -> None:
+ connection = op.get_bind()
+
+ # `lexicon_term.term` is globally unique and categories share terms through
+ # an association table, so `provisional` and `approved` already exist from
+ # `release_status` and `review_status`. Only the intermediate state is new.
+ connection.execute(
+ sa.text(
+ "INSERT INTO lexicon_term (term, definition) VALUES (:term, :definition) "
+ "ON CONFLICT (term) DO NOTHING"
+ ),
+ {
+ "term": "in review",
+ "definition": (
+ "Under review and not yet approved. Intermediate state from the "
+ "USGS Aquarius approval levels used for continuous records."
+ ),
+ },
+ )
+ connection.execute(
+ sa.text(
+ "INSERT INTO lexicon_category (name) VALUES (:name) "
+ "ON CONFLICT (name) DO NOTHING"
+ ),
+ {"name": CATEGORY},
+ )
+ connection.execute(
+ sa.text(
+ """
+ INSERT INTO lexicon_term_category_association (term_id, category_id)
+ SELECT t.id, c.id
+ FROM lexicon_term t, lexicon_category c
+ WHERE t.term = ANY(:terms) AND c.name = :category
+ ON CONFLICT DO NOTHING
+ """
+ ),
+ {"terms": list(TERMS), "category": CATEGORY},
+ )
+
+ op.add_column(
+ "transducer_observation",
+ sa.Column(
+ "data_maturity",
+ sa.String(length=100),
+ nullable=True,
+ comment=(
+ "How far through review this reading is. Orthogonal to "
+ "release_status, which controls visibility. NULL means not stated."
+ ),
+ ),
+ )
+ op.create_foreign_key(
+ "fk_transducer_observation_data_maturity",
+ "transducer_observation",
+ "lexicon_term",
+ ["data_maturity"],
+ ["term"],
+ onupdate="CASCADE",
+ )
+
+
+def downgrade() -> None:
+ op.drop_constraint(
+ "fk_transducer_observation_data_maturity",
+ "transducer_observation",
+ type_="foreignkey",
+ )
+ op.drop_column("transducer_observation", "data_maturity")
+
+ # The terms are left in place. They may have been adopted elsewhere by the
+ # time this is reversed, and an unused lexicon term is harmless where a
+ # missing one breaks a foreign key.
diff --git a/automated_ingestion/ocotillo/loader.py b/automated_ingestion/ocotillo/loader.py
index a4eb0d92e..d867f66c6 100644
--- a/automated_ingestion/ocotillo/loader.py
+++ b/automated_ingestion/ocotillo/loader.py
@@ -71,6 +71,16 @@ def _batched(records: Iterable[Any], size: int) -> Iterator[list[Any]]:
yield batch
+DEFAULT_DATA_MATURITY = "provisional"
+"""Maturity for a freshly ingested reading.
+
+USGS publishes unapproved records as provisional -- "provisional data subject to
+revision" -- and that is what a diver reading is until somebody reviews it.
+Orthogonal to ``release_status``: San Acacia data is public *and* provisional,
+which is why this is a second column rather than another value in the first.
+"""
+
+
def load_observations(
session: Any,
records: Iterable[Any],
@@ -78,6 +88,7 @@ def load_observations(
parameter_id: int,
release_status: str,
batch_size: int = DEFAULT_BATCH_SIZE,
+ data_maturity: str = DEFAULT_DATA_MATURITY,
) -> LoadResult:
"""Upsert observations, committing per batch.
@@ -100,6 +111,7 @@ def load_observations(
"observation_datetime": record.observation_datetime,
"value": record.value,
"release_status": release_status,
+ "data_maturity": data_maturity,
}
for record in batch
]
@@ -115,7 +127,10 @@ def load_observations(
"parameter_id",
"observation_datetime",
],
- set_={"value": statement.excluded.value},
+ set_={
+ "value": statement.excluded.value,
+ "data_maturity": statement.excluded.data_maturity,
+ },
)
session.execute(statement)
session.commit()
diff --git a/core/enums.py b/core/enums.py
index 663f367ef..790272125 100644
--- a/core/enums.py
+++ b/core/enums.py
@@ -18,6 +18,7 @@
from services.lexicon_helper import build_enum_from_lexicon_category
ActivityType: type[Enum] = build_enum_from_lexicon_category("activity_type")
+DataMaturity: type[Enum] = build_enum_from_lexicon_category("data_maturity")
AddressType: type[Enum] = build_enum_from_lexicon_category("address_type")
AnalysisMethodType: type[Enum] = build_enum_from_lexicon_category(
"analysis_method_type"
diff --git a/core/lexicon.json b/core/lexicon.json
index 813428dfb..40291d696 100644
--- a/core/lexicon.json
+++ b/core/lexicon.json
@@ -243,12 +243,17 @@
{
"name": "lithology",
"description": null
+ },
+ {
+ "name": "data_maturity",
+ "description": "How far through review a measurement is, on USGS terms. Orthogonal to release_status, which controls visibility rather than trust."
}
],
"terms": [
{
"categories": [
- "review_status"
+ "review_status",
+ "data_maturity"
],
"term": "approved",
"definition": "approved"
@@ -1762,7 +1767,8 @@
},
{
"categories": [
- "release_status"
+ "release_status",
+ "data_maturity"
],
"term": "provisional",
"definition": "provisional version"
@@ -8495,6 +8501,13 @@
],
"term": "Data not field checked, but considered reliable",
"definition": "Data were not field checked but are considered reliable"
+ },
+ {
+ "categories": [
+ "data_maturity"
+ ],
+ "term": "in review",
+ "definition": "Under review and not yet approved. Intermediate state from the USGS Aquarius approval levels used for continuous records."
}
]
}
\ No newline at end of file
diff --git a/db/transducer.py b/db/transducer.py
index 57625e3f8..d109adc58 100644
--- a/db/transducer.py
+++ b/db/transducer.py
@@ -136,6 +136,15 @@ class TransducerObservation(Base, AutoBaseMixin, ReleaseMixin):
DateTime(timezone=True), nullable=False, index=True
)
value: Mapped[float] = mapped_column(Float, nullable=False)
+
+ # How far through review this reading is, on USGS terms: provisional,
+ # in review, approved. Orthogonal to `release_status`, which says who may
+ # see it -- a reading can be public and provisional at once, which one
+ # column could not express because its lexicon lists those as siblings.
+ #
+ # Nullable because legacy rows predate it and nobody has established
+ # whether they are approved. NULL means not stated, which is honest.
+ data_maturity: Mapped[str] = lexicon_term(nullable=True)
nma_waterlevelscontinuous_pressure_conddl_ms_cm: Mapped[float] = mapped_column(
Float, nullable=True
)
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index e07edd73b..d4e940020 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -277,13 +277,24 @@ Some of the 33 may already exist in Ocotillo under Bureau point IDs. Duplicates
### 3.3 — Represent "public but provisional"
-`release_status` is one scalar column and its lexicon category holds `public` and `provisional` as siblings, so both cannot be set. Visibility and maturity are orthogonal axes.
+Built. Migration `b2c3d4e5f6a7` adds `data_maturity` to `transducer_observation`.
-- Decide the representation. Recommended: keep `release_status = "public"` for visibility, add an explicit maturity field (`is_provisional` boolean, or a `data_maturity` lexicon term) on `TransducerObservation` / `TransducerObservationBlock`. Rejected alternative: overloading `review_status`, which means Bureau review and carries a `reviewer_id` FK.
-- Follow the Model Change Workflow in `CLAUDE.md`: db model → schemas → alembic migration → tests → transfer scripts.
-- Provisional state is visible wherever the data surfaces — API responses and the Hydrograph Corrector.
-- Check the blast radius of `release_status = "public"` before shipping: `services/ngwmn_helper.py` filters `Thing.release_status == "public"` for NGWMN publication. Confirm San Acacia data becoming public is intended there too.
-- Existing rows keep their current behavior; the migration has a defined default.
+**Decided: a `data_maturity` lexicon term, not an `is_provisional` boolean.** A boolean can only say provisional or not, and review is a progression rather than a switch.
+
+**Terms follow USGS usage** — `provisional`, `in review`, `approved`. `provisional` and `approved` are what USGS publishes against ("provisional data subject to revision" is the standard caveat on unapproved records). `in review` is the intermediate state from the Aquarius approval levels USGS uses for continuous time series (Working / In Review / Approved); Aquarius' `Working` is folded into `provisional`, because to a consumer the two are indistinguishable.
+
+- ✅ `release_status` keeps meaning visibility; `data_maturity` means trust. A reading can be `public` **and** `provisional` at once, which one column could not express — its lexicon lists them as siblings. There is a test asserting exactly that pair.
+- ✅ `DataMaturity` enum, built from `core/lexicon.json` like every other status enum. That file is the source of truth the enums read; the migration seeds the database to match.
+- ✅ Exposed on `TransducerObservationResponse` and accepted on `CreateTransducerObservation`, both nullable.
+- ✅ The loader defaults new readings to `provisional`, and an upsert refreshes maturity along with value — a corrected reading arriving as approved must not keep the older maturity.
+- ✅ The column is a foreign key onto `lexicon_term`, so a typo is rejected by the database. Tested.
+- ✅ Migration verified up and down against a database with 88,666 observations.
+
+**Existing rows are left NULL, not defaulted.** Backfilling 88,666 legacy observations to `provisional` would assert something about NMA data nobody has checked — some may be approved. NULL reads as "not stated", which is true.
+
+`provisional` and `approved` already existed as terms: `lexicon_term.term` is globally unique and categories share terms through an association table, so only `in review` is new. That means `approved` is now shared by `review_status` and `data_maturity`. They are asking different questions — `review_status` on the block records that a Bureau human reviewed it and carries a `reviewer_id`, while `data_maturity` describes the reading's revision state — and the shared vocabulary is how this lexicon is designed to work.
+
+⬜ Blast radius still to check: `services/ngwmn_helper.py` filters `Thing.release_status == "public"` for NGWMN publication. San Acacia data becoming public needs to be intended there too.
### 3.4 — Unique constraint on `transducer_observation` + idempotent upsert loader
diff --git a/schemas/group.py b/schemas/group.py
index 2472dc0fa..cf2f04110 100644
--- a/schemas/group.py
+++ b/schemas/group.py
@@ -27,6 +27,7 @@ class ValidateGroup(BaseModel):
project_area: str | None = None
description: str | None = None
parent_group_id: int | None = None
+ group_type: GroupType | None = None
@field_validator("project_area")
def validate_area_is_wkt(cls, wkt):
diff --git a/schemas/transducer.py b/schemas/transducer.py
index 4232cdf5b..f11be79aa 100644
--- a/schemas/transducer.py
+++ b/schemas/transducer.py
@@ -17,7 +17,7 @@
from pydantic import BaseModel
-from core.enums import ReviewStatus
+from core.enums import DataMaturity, ReviewStatus
from schemas import BaseResponseModel, BaseCreateModel
@@ -34,6 +34,9 @@ class TransducerObservationResponse(BaseResponseModel):
observation_datetime: datetime
parameter_id: int
deployment_id: int
+ # Nullable: readings loaded before the field existed do not state a
+ # maturity, and asserting one for them would be an invention.
+ data_maturity: DataMaturity | None
class TransducerObservationWithBlockResponse(BaseModel):
@@ -47,6 +50,7 @@ class CreateTransducerObservation(BaseCreateModel):
deployment_id: int
value: float
observation_datetime: datetime
+ data_maturity: DataMaturity | None = None
# ============= EOF =============================================
diff --git a/tests/test_transducer_loader.py b/tests/test_transducer_loader.py
index f6cefdc0d..ec3f85355 100644
--- a/tests/test_transducer_loader.py
+++ b/tests/test_transducer_loader.py
@@ -114,4 +114,80 @@ def test_batches_commit_separately(loader_target):
assert _count(session, deployment_id) == 25
+def test_loaded_readings_are_provisional_by_default(loader_target):
+ # USGS publishes unapproved records as provisional. A diver reading is that
+ # until somebody reviews it.
+ deployment_id, parameter_id = loader_target
+ with session_ctx() as session:
+ load_observations(session, _records(1), deployment_id, parameter_id, "draft")
+ row = session.execute(
+ select(
+ TransducerObservation.data_maturity,
+ TransducerObservation.release_status,
+ ).where(TransducerObservation.deployment_id == deployment_id)
+ ).one()
+ assert row.data_maturity == "provisional"
+
+
+def test_public_and_provisional_can_both_be_true(loader_target):
+ # The reason this is a second column: release_status lists public and
+ # provisional as siblings, so one column could not express both.
+ deployment_id, parameter_id = loader_target
+ with session_ctx() as session:
+ load_observations(session, _records(1), deployment_id, parameter_id, "public")
+ row = session.execute(
+ select(
+ TransducerObservation.data_maturity,
+ TransducerObservation.release_status,
+ ).where(TransducerObservation.deployment_id == deployment_id)
+ ).one()
+ assert (row.release_status, row.data_maturity) == ("public", "provisional")
+
+
+def test_a_correction_refreshes_maturity_too(loader_target):
+ # Re-loading an approved value must not leave the earlier maturity behind.
+ deployment_id, parameter_id = loader_target
+ with session_ctx() as session:
+ load_observations(session, _records(1), deployment_id, parameter_id, "draft")
+ load_observations(
+ session,
+ _records(1, value=99.0),
+ deployment_id,
+ parameter_id,
+ "draft",
+ data_maturity="approved",
+ )
+ row = session.execute(
+ select(
+ TransducerObservation.value, TransducerObservation.data_maturity
+ ).where(TransducerObservation.deployment_id == deployment_id)
+ ).one()
+ assert (row.value, row.data_maturity) == (99.0, "approved")
+
+
+def test_maturity_must_be_a_lexicon_term(loader_target):
+ # The column is a foreign key onto lexicon_term, so a typo is rejected by
+ # the database rather than stored and puzzled over later.
+ #
+ # DatabaseError rather than IntegrityError: pg8000 reports a foreign key
+ # violation as a ProgrammingError, and SQLAlchemy preserves that. Both
+ # descend from DatabaseError, so this catches the violation without
+ # asserting which driver is underneath.
+ import pytest
+ from sqlalchemy.exc import DatabaseError
+
+ deployment_id, parameter_id = loader_target
+ with session_ctx() as session:
+ with pytest.raises(DatabaseError):
+ load_observations(
+ session,
+ _records(1),
+ deployment_id,
+ parameter_id,
+ "draft",
+ data_maturity="probational",
+ )
+ session.rollback()
+
+
# ============= EOF =============================================
From 9c380485baad17dc04dca39f0b02395400175d12 Mon Sep 17 00:00:00 2001
From: jirhiker <2035568+jirhiker@users.noreply.github.com>
Date: Wed, 19 Aug 2026 16:13:33 +0000
Subject: [PATCH 101/151] Formatting changes
---
alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py b/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py
index 5b464b1d2..71beb6864 100644
--- a/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py
+++ b/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py
@@ -61,15 +61,13 @@ def upgrade() -> None:
{"name": CATEGORY},
)
connection.execute(
- sa.text(
- """
+ sa.text("""
INSERT INTO lexicon_term_category_association (term_id, category_id)
SELECT t.id, c.id
FROM lexicon_term t, lexicon_category c
WHERE t.term = ANY(:terms) AND c.name = :category
ON CONFLICT DO NOTHING
- """
- ),
+ """),
{"terms": list(TERMS), "category": CATEGORY},
)
From 95b9b78b8dbd74b5ee08859bb48d57ff3192ec75 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 09:17:43 -0700
Subject: [PATCH 102/151] fix(transducer): backfill data_maturity from the
legacy QC flag
I left historical rows NULL on the grounds that nobody had established whether
legacy NMA data was approved. The evidence was in the same table:
nma_waterlevelscontinuous_pressure_qced records whether a reading was quality
controlled, which is the question data_maturity asks.
True becomes approved, false becomes provisional. All 88,666 rows in the
development database are qced, so they land as approved rather than as an
absence somebody would have to reconstruct later.
Rows where the flag is NULL stay NULL. Those did not come from the NMA
transducer tables, so there is no evidence either way, and NULL is honest where
a guess would not be.
The update runs after the foreign key is in place, so a bad value fails loudly
rather than persisting.
Co-Authored-By: Claude Opus 5
---
.../b2c3d4e5f6a7_transducer_data_maturity.py | 28 ++++++++++++++++---
docs/automated-ingestion-pipeline-plan.md | 4 ++-
2 files changed, 27 insertions(+), 5 deletions(-)
diff --git a/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py b/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py
index 71beb6864..e9d63171d 100644
--- a/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py
+++ b/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py
@@ -16,10 +16,14 @@
Approved); Aquarius' `Working` is folded into `provisional` because the two are
indistinguishable to a consumer.
-**Existing rows are left NULL rather than defaulted.** Backfilling 88,000+
-observations to `provisional` would assert something about legacy NMA data that
-nobody has checked -- some of it may well be approved. NULL reads as "not
-stated", which is true.
+Existing rows are backfilled from the legacy AMPAPI QC flag,
+`nma_waterlevelscontinuous_pressure_qced`, which records exactly this: whether a
+reading has been quality controlled. True becomes `approved`, false becomes
+`provisional`.
+
+Rows where that flag is NULL stay NULL. Those did not come from the NMA
+transducer tables, so there is no evidence either way, and NULL reads as "not
+stated" -- which is true, where guessing would not be.
"""
import sqlalchemy as sa
@@ -92,6 +96,22 @@ def upgrade() -> None:
onupdate="CASCADE",
)
+ # The legacy QC flag answers this question directly, so the maturity of
+ # historical rows is a lookup rather than a guess. Done after the foreign
+ # key so a bad value here would fail loudly rather than persist.
+ connection.execute(
+ sa.text(
+ """
+ UPDATE transducer_observation
+ SET data_maturity = CASE
+ WHEN nma_waterlevelscontinuous_pressure_qced THEN 'approved'
+ ELSE 'provisional'
+ END
+ WHERE nma_waterlevelscontinuous_pressure_qced IS NOT NULL
+ """
+ )
+ )
+
def downgrade() -> None:
op.drop_constraint(
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index d4e940020..36052df1c 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -290,7 +290,9 @@ Built. Migration `b2c3d4e5f6a7` adds `data_maturity` to `transducer_observation`
- ✅ The column is a foreign key onto `lexicon_term`, so a typo is rejected by the database. Tested.
- ✅ Migration verified up and down against a database with 88,666 observations.
-**Existing rows are left NULL, not defaulted.** Backfilling 88,666 legacy observations to `provisional` would assert something about NMA data nobody has checked — some may be approved. NULL reads as "not stated", which is true.
+**Existing rows are backfilled from the legacy QC flag.** `transducer_observation` already carries `nma_waterlevelscontinuous_pressure_qced`, the AMPAPI field recording whether a reading was quality controlled — the same question `data_maturity` asks. True becomes `approved`, false becomes `provisional`. All 88,666 rows in the development database are `qced = true`, so they land as `approved`.
+
+Rows where that flag is NULL stay NULL: they did not come from the NMA transducer tables, so there is no evidence either way.
`provisional` and `approved` already existed as terms: `lexicon_term.term` is globally unique and categories share terms through an association table, so only `in review` is new. That means `approved` is now shared by `review_status` and `data_maturity`. They are asking different questions — `review_status` on the block records that a Bureau human reviewed it and carries a `reviewer_id`, while `data_maturity` describes the reading's revision state — and the shared vocabulary is how this lexicon is designed to work.
From 027632f63a5bfad773962449018fd36c98fca1eb Mon Sep 17 00:00:00 2001
From: jirhiker <2035568+jirhiker@users.noreply.github.com>
Date: Wed, 19 Aug 2026 16:18:28 +0000
Subject: [PATCH 103/151] Formatting changes
---
alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py b/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py
index e9d63171d..3037e3cd0 100644
--- a/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py
+++ b/alembic/versions/b2c3d4e5f6a7_transducer_data_maturity.py
@@ -99,18 +99,14 @@ def upgrade() -> None:
# The legacy QC flag answers this question directly, so the maturity of
# historical rows is a lookup rather than a guess. Done after the foreign
# key so a bad value here would fail loudly rather than persist.
- connection.execute(
- sa.text(
- """
+ connection.execute(sa.text("""
UPDATE transducer_observation
SET data_maturity = CASE
WHEN nma_waterlevelscontinuous_pressure_qced THEN 'approved'
ELSE 'provisional'
END
WHERE nma_waterlevelscontinuous_pressure_qced IS NOT NULL
- """
- )
- )
+ """))
def downgrade() -> None:
From 5a50381b243569e64348ab1b5f95db3509ba8e64 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 09:47:27 -0700
Subject: [PATCH 104/151] fix(ingestion): do not overwrite approved
observations by default
Ocotillo already holds 542,161 approved observations for 14 of the 38 San Acacia
wells, from the AMPAPI transfer and running to August 2022. The upsert would
have replaced every one of them with a vendor reading and downgraded it to
provisional, silently, on any backfill covering that window.
DO UPDATE exists so a vendor correction can revise our own provisional
readings. Applying it to reviewed history from another source is a different
act, and it should be one somebody chooses: overwrite_approved defaults to
False and the conflict clause skips approved rows.
Rows with NULL maturity still update. Unknown is not approved, and treating it
as such would freeze the 394,086 legacy rows with no QC record against every
future correction.
Still owed: those AMPAPI rows were loaded under whatever datum that pipeline
used, and ours are ground-surface centimetres converted to feet. Overlapping
timestamps should be compared before any window covering 2016-2022 is loaded.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/ocotillo/loader.py | 32 ++++++++--
docs/automated-ingestion-pipeline-plan.md | 14 ++++
tests/test_transducer_loader.py | 78 +++++++++++++++++++++++
3 files changed, 120 insertions(+), 4 deletions(-)
diff --git a/automated_ingestion/ocotillo/loader.py b/automated_ingestion/ocotillo/loader.py
index d867f66c6..7b831bf82 100644
--- a/automated_ingestion/ocotillo/loader.py
+++ b/automated_ingestion/ocotillo/loader.py
@@ -89,12 +89,27 @@ def load_observations(
release_status: str,
batch_size: int = DEFAULT_BATCH_SIZE,
data_maturity: str = DEFAULT_DATA_MATURITY,
+ overwrite_approved: bool = False,
) -> LoadResult:
"""Upsert observations, committing per batch.
``records`` are ``ObservationRecord`` values from an adapter; resolving a
source's point identifier to a deployment belongs to reference-data
bootstrapping, not here, so the caller supplies the ids.
+
+ ``overwrite_approved`` guards data somebody has already reviewed. By default
+ a row whose ``data_maturity`` is ``approved`` is left alone: the upsert exists
+ so a vendor correction can revise *our* provisional readings, not so a
+ re-fetch can quietly replace Bureau-approved history with a vendor's numbers
+ and downgrade it to provisional.
+
+ This is not hypothetical. Fourteen of the thirty-eight San Acacia wells
+ already hold 542,161 approved observations from the AMPAPI transfer, running
+ to August 2022. A Mode A backfill over that window would have overwritten
+ every one of them.
+
+ Setting it to True is a deliberate act: it says the incoming data is better
+ than what was reviewed, which is a judgement a person should make.
"""
from sqlalchemy.dialects.postgresql import insert
@@ -121,17 +136,26 @@ def load_observations(
# DO UPDATE rather than DO NOTHING: a vendor may correct a reading, and
# a correction arriving as a no-op would leave the old value in place
# while the run reported success.
- statement = statement.on_conflict_do_update(
- index_elements=[
+ conflict_kwargs: dict[str, Any] = {
+ "index_elements": [
"deployment_id",
"parameter_id",
"observation_datetime",
],
- set_={
+ "set_": {
"value": statement.excluded.value,
"data_maturity": statement.excluded.data_maturity,
},
- )
+ }
+ if not overwrite_approved:
+ # IS DISTINCT FROM rather than != so NULL maturity still updates:
+ # a row with no recorded status has not been reviewed, and treating
+ # unknown as approved would freeze 394,086 legacy rows against every
+ # future correction.
+ conflict_kwargs["where"] = table.c.data_maturity.is_distinct_from(
+ "approved"
+ )
+ statement = statement.on_conflict_do_update(**conflict_kwargs)
session.execute(statement)
session.commit()
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index 72aaff0e6..028321bd7 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -325,6 +325,20 @@ Built. Migration `a1b2c3d4e5f6`, loader in `automated_ingestion/ocotillo/loader.
⬜ Run the duplicate report against production and staging before applying the migration. The local development database was clean — 0 duplicate groups in 88,666 rows — which is encouraging and not evidence about production.
+### Existing San Acacia data — measured 2026-08-19
+
+Ocotillo already holds transducer observations for **14 of the 38 wells**: 542,161 rows from the AMPAPI transfer, running 2016-07-08 to **2022-08-03**. They carry a real QC status, so `data_maturity` backfilled them as `approved`.
+
+Consequences, all of which the earlier plan assumed away:
+
+- **The watermark starts at 2022-08-03 for those 14**, not the 2015 floor, so a normal run fetches a four-year gap rather than a decade. The other 24 wells do start at the floor.
+- **A backfill would have overwritten them.** The upsert's `DO UPDATE` was written for vendor corrections to our own provisional readings; against approved AMPAPI history it would have replaced 542,161 reviewed values with vendor numbers *and* downgraded them to provisional. `load_observations` now refuses to touch an `approved` row unless `overwrite_approved=True` is passed deliberately.
+- **A datum comparison is still owed.** Those rows came from AMPAPI under whatever convention that pipeline used; ours are Diver-HUB ground-surface centimetres converted to feet. Before any window overlapping 2016–2022 is loaded, a few coinciding timestamps should be compared. Same failure shape as the `WaterLevelReference` question: plausible numbers, wrong meaning.
+
+Rows with `NULL` maturity still update. Unknown is not approved, and treating it as such would freeze the 394,086 legacy rows that have no QC record against every future correction.
+
+**The wider table**, for context: 2,180,989 approved, 7,351 provisional, 394,086 NULL. The NULL cohort is 176 deployments on a single parameter spanning 2016 to February 2025 with no AMPAPI provenance at all — a separate network, and **none of the 38 San Acacia wells are in it**. Worth identifying independently of this work.
+
### 3.5 — Watermark from Postgres
Built. `automated_ingestion/shared/watermark.py`, seven tests.
diff --git a/tests/test_transducer_loader.py b/tests/test_transducer_loader.py
index ec3f85355..8913713da 100644
--- a/tests/test_transducer_loader.py
+++ b/tests/test_transducer_loader.py
@@ -190,4 +190,82 @@ def test_maturity_must_be_a_lexicon_term(loader_target):
session.rollback()
+def test_approved_rows_are_not_overwritten(loader_target):
+ # 14 of the 38 San Acacia wells already hold 542,161 approved observations
+ # from the AMPAPI transfer. A backfill over that window must not replace
+ # reviewed values with a vendor's numbers.
+ deployment_id, parameter_id = loader_target
+ with session_ctx() as session:
+ load_observations(
+ session,
+ _records(1, value=10.0),
+ deployment_id,
+ parameter_id,
+ "draft",
+ data_maturity="approved",
+ )
+ load_observations(
+ session, _records(1, value=99.0), deployment_id, parameter_id, "draft"
+ )
+ row = session.execute(
+ select(
+ TransducerObservation.value, TransducerObservation.data_maturity
+ ).where(TransducerObservation.deployment_id == deployment_id)
+ ).one()
+ assert (row.value, row.data_maturity) == (10.0, "approved")
+
+
+def test_approved_rows_can_be_overwritten_deliberately(loader_target):
+ deployment_id, parameter_id = loader_target
+ with session_ctx() as session:
+ load_observations(
+ session,
+ _records(1, value=10.0),
+ deployment_id,
+ parameter_id,
+ "draft",
+ data_maturity="approved",
+ )
+ load_observations(
+ session,
+ _records(1, value=99.0),
+ deployment_id,
+ parameter_id,
+ "draft",
+ overwrite_approved=True,
+ )
+ value = session.scalar(
+ select(TransducerObservation.value).where(
+ TransducerObservation.deployment_id == deployment_id
+ )
+ )
+ assert value == 99.0
+
+
+def test_rows_with_no_recorded_maturity_still_update(loader_target):
+ # 394,086 legacy rows have NULL maturity. Unknown is not approved, and
+ # treating it as such would freeze them against every future correction.
+ deployment_id, parameter_id = loader_target
+ with session_ctx() as session:
+ load_observations(
+ session, _records(1, value=10.0), deployment_id, parameter_id, "draft"
+ )
+ session.execute(
+ TransducerObservation.__table__.update()
+ .where(TransducerObservation.deployment_id == deployment_id)
+ .values(data_maturity=None)
+ )
+ session.commit()
+
+ load_observations(
+ session, _records(1, value=99.0), deployment_id, parameter_id, "draft"
+ )
+ value = session.scalar(
+ select(TransducerObservation.value).where(
+ TransducerObservation.deployment_id == deployment_id
+ )
+ )
+ assert value == 99.0
+
+
# ============= EOF =============================================
From 113fdd6f4d8ef1f786c9bc8dfd27650d2b2f5ac2 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 10:35:37 -0700
Subject: [PATCH 105/151] feat(ingestion): wire the loader end to end
san_acacia_observations joins the pieces that existed separately: reconcile the
vendor point to a well, choose the deployment its transducer hangs from, ask the
database where that series got to, fetch forward, map, upsert, and extend the QC
block.
The seeding half of 3.2 turned out to be nothing. All 38 wells already have
deployments, the parameter exists as `groundwater level` in feet -- the unit the
adapter emits -- and existing observations already use it. So the series is
chosen rather than created.
Choosing it needs a rule, because a well carries several open deployments: a
deployment is equipment, not a measured property. SO-0140 has a DiverLink, a
Pressure Transducer and a Diver Cable, and only the transducer produces a water
level. Picking any other would attribute a reading to a cable.
That resolves cleanly for 35 of the 38 wells. Two have two open transducers and
one has none; those are skipped and reported. Taking the lower id would be a
silent guess about equipment, and a removed transducer is not used as a fallback
-- writing current data against retired kit looks like success while being
wrong.
A well that cannot be resolved costs that well's readings for the run, not the
other thirty-seven's.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/defs/assets/__init__.py | 2 +
.../sources/san_acacia/ingest.py | 184 ++++++++++++++++++
.../sources/san_acacia/resolve.py | 111 +++++++++++
automated_ingestion/tests/test_resolve.py | 93 +++++++++
docs/automated-ingestion-pipeline-plan.md | 12 ++
5 files changed, 402 insertions(+)
create mode 100644 automated_ingestion/sources/san_acacia/resolve.py
create mode 100644 automated_ingestion/tests/test_resolve.py
diff --git a/automated_ingestion/defs/assets/__init__.py b/automated_ingestion/defs/assets/__init__.py
index 3671abfa5..4e7a0494c 100644
--- a/automated_ingestion/defs/assets/__init__.py
+++ b/automated_ingestion/defs/assets/__init__.py
@@ -27,6 +27,7 @@
from automated_ingestion.sources.san_acacia.ingest import (
raw_san_acacia_locations,
raw_san_acacia_readings,
+ san_acacia_observations,
)
@@ -37,6 +38,7 @@ def all_assets() -> list[AssetsDefinition]:
database_connectivity,
raw_san_acacia_locations,
raw_san_acacia_readings,
+ san_acacia_observations,
]
diff --git a/automated_ingestion/sources/san_acacia/ingest.py b/automated_ingestion/sources/san_acacia/ingest.py
index ff318d9fc..52228c4da 100644
--- a/automated_ingestion/sources/san_acacia/ingest.py
+++ b/automated_ingestion/sources/san_acacia/ingest.py
@@ -27,6 +27,7 @@
from dagster import AssetExecutionContext, MetadataValue, Output, asset
+from automated_ingestion.defs.resources import OcotilloDatabase
from automated_ingestion.sources.san_acacia.client import DiverHubClient
@@ -113,6 +114,189 @@ def raw_san_acacia_readings(context: AssetExecutionContext) -> Output[int]:
)
+@asset(
+ group_name="san_acacia",
+ deps=[raw_san_acacia_readings],
+ description="Water levels mapped to the Ocotillo model and loaded to Postgres.",
+)
+def san_acacia_observations(
+ context: AssetExecutionContext, database: OcotilloDatabase
+) -> Output[int]:
+ """Load San Acacia water levels into `transducer_observation`.
+
+ Per well: match the vendor point to an Ocotillo well, choose the deployment
+ its transducer hangs from, ask the database where that series got to, fetch
+ forward from there, map, and upsert.
+
+ A well that cannot be resolved is skipped and counted, never guessed at.
+ Ingestion does not create wells or pick between candidate deployments, so an
+ unresolved well is a question for a person -- and skipping it costs that
+ well's readings for this run, not the other thirty-seven's.
+ """
+ from datetime import datetime, timezone
+
+ from automated_ingestion.ocotillo.loader import ensure_block, load_observations
+ from automated_ingestion.shared.watermark import (
+ PostgresWatermarkStore,
+ resolve_start,
+ )
+ from automated_ingestion.sources.san_acacia.adapter import SanAcaciaAdapter
+ from automated_ingestion.sources.san_acacia.client import GROUND_SURFACE_REFERENCE
+ from automated_ingestion.sources.san_acacia.dlt_pipeline import (
+ INITIAL_START,
+ PROJECT_ID,
+ READING_SPAN,
+ )
+ from automated_ingestion.sources.san_acacia.reconcile import (
+ VendorPoint,
+ reconcile,
+ )
+ from automated_ingestion.sources.san_acacia.resolve import (
+ PARAMETER_NAME,
+ resolve_deployment,
+ )
+ from domain.van_essen import parse_reading_timestamp
+
+ client = _client()
+ points = [
+ VendorPoint(monitoring_point_id=p["id"], name=p["name"])
+ for p in client.monitoring_points(PROJECT_ID)
+ ]
+ end = int(datetime.now(tz=timezone.utc).timestamp())
+ floor = parse_reading_timestamp(INITIAL_START)
+
+ rows_loaded = 0
+ skipped: list[dict[str, Any]] = []
+ adapter_failures = 0
+
+ with database.session() as session:
+ parameter_id = _parameter_id(session, PARAMETER_NAME)
+ report = reconcile(points, _well_candidates(session))
+ watermarks = PostgresWatermarkStore(session)
+
+ for match in report.matches:
+ if match.needs_a_human:
+ skipped.append({"point": match.point.name, "reason": match.kind.value})
+ continue
+
+ thing_id = match.thing_id
+ resolution = resolve_deployment(_deployments(session, thing_id))
+ if resolution.needs_a_human:
+ skipped.append(
+ {"point": match.point.name, "reason": resolution.kind.value}
+ )
+ continue
+
+ start = resolve_start(watermarks, thing_id, parameter_id, floor)
+ adapter = SanAcaciaAdapter()
+ raw = (
+ {
+ "monitoring_point_id": match.point.monitoring_point_id,
+ "dateAndTime": row["dateAndTime"],
+ "level": row["level"],
+ "unit": "cm",
+ "reference": GROUND_SURFACE_REFERENCE,
+ }
+ for row in client.water_levels(
+ match.point.monitoring_point_id,
+ int(start.timestamp()),
+ end,
+ reference=GROUND_SURFACE_REFERENCE,
+ span=READING_SPAN,
+ )
+ )
+
+ observations = list(adapter.to_observations(raw))
+ adapter_failures += len(adapter.failures)
+ if not observations:
+ continue
+
+ result = load_observations(
+ session,
+ observations,
+ resolution.deployment_id,
+ parameter_id,
+ release_status="public",
+ )
+ rows_loaded += result.rows_written
+ ensure_block(
+ session,
+ thing_id=thing_id,
+ parameter_id=parameter_id,
+ start=min(o.observation_datetime for o in observations),
+ end=max(o.observation_datetime for o in observations),
+ release_status="public",
+ )
+
+ if skipped:
+ context.log.warning(
+ "%s of %s wells skipped: %s",
+ len(skipped),
+ len(points),
+ ", ".join(f"{s['point']} ({s['reason']})" for s in skipped),
+ )
+
+ return Output(
+ rows_loaded,
+ metadata={
+ "rows_loaded": MetadataValue.int(rows_loaded),
+ "wells_attempted": MetadataValue.int(len(points)),
+ "wells_skipped": MetadataValue.int(len(skipped)),
+ "adapter_failures": MetadataValue.int(adapter_failures),
+ "skipped": MetadataValue.json(skipped),
+ },
+ )
+
+
+def _parameter_id(session: Any, name: str) -> int:
+ from sqlalchemy import select
+
+ from db.parameter import Parameter
+
+ parameter_id = session.scalar(
+ select(Parameter.id).where(Parameter.parameter_name == name)
+ )
+ if parameter_id is None:
+ raise RuntimeError(
+ f"No parameter named {name!r}. Ingestion does not create parameters; "
+ "seed it before loading."
+ )
+ return parameter_id
+
+
+def _well_candidates(session: Any) -> list[Any]:
+ """Ocotillo wells the vendor points might be, narrowed by name prefix."""
+ from sqlalchemy import select
+
+ from db.thing import Thing
+
+ from automated_ingestion.sources.san_acacia.reconcile import ThingCandidate
+
+ rows = session.execute(
+ select(Thing.id, Thing.name).where(Thing.name.ilike("SO-%"))
+ ).all()
+ return [ThingCandidate(thing_id=i, name=n) for i, n in rows]
+
+
+def _deployments(session: Any, thing_id: int) -> list[Any]:
+ from sqlalchemy import select
+
+ from db.deployment import Deployment
+ from db.sensor import Sensor
+
+ from automated_ingestion.sources.san_acacia.resolve import DeploymentCandidate
+
+ rows = session.execute(
+ select(Deployment.id, Sensor.sensor_type, Deployment.removal_date)
+ .join(Sensor, Sensor.id == Deployment.sensor_id)
+ .where(Deployment.thing_id == thing_id)
+ ).all()
+ return [
+ DeploymentCandidate(deployment_id=i, sensor_type=t, removal_date=r)
+ for i, t, r in rows
+ ]
+
+
def _row_count(load_info: Any) -> int:
"""Rows dlt reports as loaded, or 0 when it reports nothing."""
try:
diff --git a/automated_ingestion/sources/san_acacia/resolve.py b/automated_ingestion/sources/san_acacia/resolve.py
new file mode 100644
index 000000000..68638976d
--- /dev/null
+++ b/automated_ingestion/sources/san_acacia/resolve.py
@@ -0,0 +1,111 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Choosing which deployment a water level belongs to.
+
+A San Acacia well carries several open deployments at once, because a deployment
+is a piece of equipment rather than a measured property. SO-0140 has three:
+
+ DiverLink DN431-1ch telemetry
+ Pressure Transducer DI801 10m measures the water level
+ Diver Cable AS2006-6m the cable
+
+Only the pressure transducer produces the reading being ingested, so that is the
+deployment an observation hangs from. Picking any of the others would attribute
+a water level to a cable.
+
+Like the reconciler, this never chooses between equally good candidates. Two
+open transducers on one well is a question about the equipment record, not
+something to resolve by taking the lower id.
+"""
+
+from collections.abc import Iterable
+from dataclasses import dataclass
+from datetime import date
+from enum import Enum
+
+WATER_LEVEL_SENSOR_TYPE = "Pressure Transducer"
+"""The sensor type whose deployment carries a water level.
+
+Checked against staging: of the 38 San Acacia wells, 35 have exactly one open
+deployment of this type, 2 have two, and 1 has none. The other types present are
+`DiverLink`, `Diver Cable` and `Barometer`, none of which measure depth to
+water.
+"""
+
+PARAMETER_NAME = "groundwater level"
+"""The Ocotillo parameter these readings are. Its `default_unit` is `ft`, which
+is what the adapter emits -- the conversion from the vendor's centimetres
+happens in `domain/van_essen.py`."""
+
+
+class ResolutionKind(str, Enum):
+ RESOLVED = "resolved"
+ AMBIGUOUS = "ambiguous"
+ MISSING = "missing"
+
+
+@dataclass(frozen=True)
+class DeploymentCandidate:
+ """A deployment on the well, with the bit needed to judge it."""
+
+ deployment_id: int
+ sensor_type: str
+ removal_date: date | None = None
+
+ @property
+ def is_open(self) -> bool:
+ return self.removal_date is None
+
+
+@dataclass(frozen=True)
+class Resolution:
+ """Which deployment to load into, or why none was chosen."""
+
+ kind: ResolutionKind
+ deployment_id: int | None = None
+ candidates: tuple[int, ...] = ()
+
+ @property
+ def needs_a_human(self) -> bool:
+ return self.kind is not ResolutionKind.RESOLVED
+
+
+def resolve_deployment(candidates: Iterable[DeploymentCandidate]) -> Resolution:
+ """Pick the open pressure-transducer deployment, or refuse.
+
+ Closed deployments are excluded rather than preferred-against: a removed
+ transducer is not where today's readings belong, and treating it as a
+ fallback would quietly write current data against retired equipment.
+ """
+ open_transducers = [
+ c for c in candidates if c.is_open and c.sensor_type == WATER_LEVEL_SENSOR_TYPE
+ ]
+
+ if len(open_transducers) == 1:
+ return Resolution(
+ kind=ResolutionKind.RESOLVED,
+ deployment_id=open_transducers[0].deployment_id,
+ )
+ if len(open_transducers) > 1:
+ return Resolution(
+ kind=ResolutionKind.AMBIGUOUS,
+ candidates=tuple(c.deployment_id for c in open_transducers),
+ )
+ return Resolution(kind=ResolutionKind.MISSING)
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_resolve.py b/automated_ingestion/tests/test_resolve.py
new file mode 100644
index 000000000..f5330ba09
--- /dev/null
+++ b/automated_ingestion/tests/test_resolve.py
@@ -0,0 +1,93 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Choosing the deployment a water level belongs to.
+
+A well carries several open deployments because a deployment is equipment, not a
+measured property. Picking the wrong one attributes a water level to a cable.
+"""
+
+from datetime import date
+
+from automated_ingestion.sources.san_acacia.resolve import (
+ DeploymentCandidate,
+ ResolutionKind,
+ resolve_deployment,
+)
+
+# The real equipment on SO-0140 in staging.
+DIVERLINK = DeploymentCandidate(436, "DiverLink")
+TRANSDUCER = DeploymentCandidate(437, "Pressure Transducer")
+CABLE = DeploymentCandidate(438, "Diver Cable")
+
+
+def test_the_transducer_is_chosen_from_a_full_nest():
+ resolution = resolve_deployment([DIVERLINK, TRANSDUCER, CABLE])
+ assert resolution.kind is ResolutionKind.RESOLVED
+ assert resolution.deployment_id == 437
+
+
+def test_a_barometer_is_not_a_water_level():
+ # Barometers are deployed on these wells too, and measure air pressure.
+ resolution = resolve_deployment([DeploymentCandidate(500, "Barometer"), TRANSDUCER])
+ assert resolution.deployment_id == 437
+
+
+def test_two_open_transducers_are_ambiguous():
+ # Two of the 38 wells are in this state. Taking the lower id would be a
+ # guess about equipment, made silently.
+ resolution = resolve_deployment(
+ [TRANSDUCER, DeploymentCandidate(600, "Pressure Transducer")]
+ )
+ assert resolution.kind is ResolutionKind.AMBIGUOUS
+ assert resolution.deployment_id is None
+ assert resolution.candidates == (437, 600)
+ assert resolution.needs_a_human
+
+
+def test_no_transducer_is_missing_not_invented():
+ # SO-0246 has no open transducer deployment at all.
+ resolution = resolve_deployment([DIVERLINK, CABLE])
+ assert resolution.kind is ResolutionKind.MISSING
+ assert resolution.deployment_id is None
+
+
+def test_a_removed_transducer_is_not_a_fallback():
+ # Writing today's readings against retired equipment would be worse than
+ # skipping the well, because it would look like it worked.
+ resolution = resolve_deployment(
+ [DeploymentCandidate(700, "Pressure Transducer", removal_date=date(2024, 1, 1))]
+ )
+ assert resolution.kind is ResolutionKind.MISSING
+
+
+def test_a_removed_transducer_does_not_make_a_live_one_ambiguous():
+ resolution = resolve_deployment(
+ [
+ DeploymentCandidate(
+ 700, "Pressure Transducer", removal_date=date(2024, 1, 1)
+ ),
+ TRANSDUCER,
+ ]
+ )
+ assert resolution.deployment_id == 437
+
+
+def test_no_deployments_at_all():
+ assert resolve_deployment([]).kind is ResolutionKind.MISSING
+
+
+# ============= EOF =============================================
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index 028321bd7..87a1349ab 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -287,6 +287,18 @@ Matching `BRN-E04A` against that returns a single confident hit on `SO-0131`, co
**This is production data, not a staging artifact.** The same contradictions are in both. They are worth someone's attention independently of this pipeline: `SO-0131`/`SO-0132` and `SO-0262`/`SO-0263` are paired shallow/deep piezometers whose A/B designations disagree between identifier sources, and a swap there means a shallow series attributed to a deep well. Ingestion is unaffected — the vendor names points `SO-####` and Ocotillo agrees on those — but anyone reasoning about these wells through the `BRN-`/`NRCS` names is working from two incompatible answers.
- ⬜ The seeding half: data migration creating missing `Location`/`Thing`, lexicon terms, DTW `Parameter`, `VanEssenDiver` `Sensor`, one `Deployment` per well, the vendor `uid` as external identifier, and `DataProvenance` for Van Essen-sourced attributes.
+### 3.2 seeding — nothing needed, measured 2026-08-19
+
+The plan expected to create wells, a parameter, a sensor and deployments. Checked against staging: **all of it already exists.**
+
+- All 38 wells have deployments — 108 open ones between them, because a deployment is a piece of equipment rather than a measured property. SO-0140 carries three: a `DiverLink` (telemetry), a `Pressure Transducer` (the reading), and a `Diver Cable`. `Barometer` appears elsewhere.
+- The parameter exists: id 1, `groundwater level`, `default_unit = ft` — which is what the adapter emits, so the centimetre conversion in `domain/van_essen.py` lands in the right unit.
+- Existing observations for these wells already use that parameter.
+
+**So the series is chosen, not created.** `sources/san_acacia/resolve.py` picks the open `Pressure Transducer` deployment. Across the 38 wells that resolves cleanly for **35**; **2** have two open transducers and **1** (SO-0246) has none. Those three are skipped and reported rather than guessed at — taking the lower id would be a silent decision about equipment.
+
+A *removed* transducer is not used as a fallback. Writing current readings against retired equipment would look like success while being wrong.
+
### 3.3 — Represent "public but provisional"
Built. Migration `b2c3d4e5f6a7` adds `data_maturity` to `transducer_observation`.
From ac7b5e86a1182316bd65ac3e79a420ce0eb972fe Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 11:02:09 -0700
Subject: [PATCH 106/151] fix(ingestion): raise the ingestion floor to 2024
Diver-HUB serves nothing before late 2024. Probing six points put their earliest
reading at 2024-10-08 and 2024-11-10, matching the deployments on these wells
being installed 2024-11-25 -- the vendor project was populated then.
INITIAL_START was 2015-01-01, chosen before anyone knew what the vendor retains.
At a 365-day span that made a first run walk twelve windows per well, ten of
them guaranteed empty, against an endpoint that answers 500 when pushed. The
floor is now 2024-01-01: three windows per well, 228 requests across the
thirty-eight rather than 912.
It sits nine months below the earliest observed reading rather than at it,
because only six of thirty-eight points were probed and a well with slightly
earlier data should not be silently truncated.
This also records why the datum comparison could not be completed. Ocotillo's
AMPAPI data for these wells ends August 2022 and the vendor starts late 2024, so
there are no matching timestamps to compare -- attempted on SO-0125 and SO-0245,
zero vendor rows at every reference. The two sources never overlap, which means
no datum mixing can occur on a normal run, and roughly twenty-seven months are
missing from the record and cannot be recovered from this source.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/scripts/compare_datum.py | 217 ++++++++++++++++++
.../scripts/diverhub_retention.py | 121 ++++++++++
.../sources/san_acacia/dlt_pipeline.py | 26 ++-
docs/sources/san_acacia.md | 32 +++
4 files changed, 391 insertions(+), 5 deletions(-)
create mode 100644 automated_ingestion/scripts/compare_datum.py
create mode 100644 automated_ingestion/scripts/diverhub_retention.py
diff --git a/automated_ingestion/scripts/compare_datum.py b/automated_ingestion/scripts/compare_datum.py
new file mode 100644
index 000000000..b513ffdf5
--- /dev/null
+++ b/automated_ingestion/scripts/compare_datum.py
@@ -0,0 +1,217 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Check that ingested readings agree with the observations Ocotillo already holds.
+
+Fourteen San Acacia wells carry AMPAPI transducer data through August 2022,
+loaded under a datum nobody has verified. This pipeline reads Diver-HUB with
+``reference=3`` and converts centimetres to feet. If those disagree, the same
+series ends up holding two datums -- and the numbers look plausible either way,
+which is the failure this source is most prone to.
+
+Magnitude alone cannot settle it: ``reference=1`` (top of casing) differs from
+``reference=3`` (ground surface) by a fixed 45.456 cm -- about 1.49 ft -- which
+is well inside the natural range of these wells. Only values at the *same
+instant* separate them, so this compares timestamp by timestamp.
+
+It fetches all four references rather than just the one in use, so the output
+also independently confirms which reference Ocotillo's existing data was loaded
+against.
+
+ export DIVERHUB_USERNAME=... DIVERHUB_PASSWORD=...
+ uv run --group ingestion python -m \\
+ automated_ingestion.scripts.compare_datum --well SO-0125
+
+Read-only on both sides.
+"""
+
+import argparse
+import statistics
+import sys
+from datetime import timedelta
+
+REFERENCES = (0, 1, 2, 3)
+
+
+def _existing(cursor, well: str, limit: int):
+ cursor.execute(
+ """
+ SELECT o.observation_datetime, o.value
+ FROM transducer_observation o
+ JOIN deployment d ON d.id = o.deployment_id
+ JOIN thing t ON t.id = d.thing_id
+ WHERE t.name = %s
+ ORDER BY o.observation_datetime DESC
+ LIMIT %s
+ """,
+ (well, limit),
+ )
+ return cursor.fetchall()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--well", default="SO-0125", help="Ocotillo PointID")
+ parser.add_argument("--point-id", type=int, help="Diver-HUB monitoring point id")
+ parser.add_argument(
+ "--instance", default="waterdatainitiative-271000:us-west4:dataservices"
+ )
+ parser.add_argument("--database", default="ocotillo-staging")
+ parser.add_argument("--samples", type=int, default=200)
+ parser.add_argument(
+ "--tolerance-minutes",
+ type=int,
+ default=30,
+ help=(
+ "How far apart two readings may be and still count as the same "
+ "instant. Exact equality is too strict: the existing rows are on the "
+ "hour and the vendor logs at 15-minute offsets."
+ ),
+ )
+ args = parser.parse_args()
+
+ import requests
+ from google.cloud.sql.connector import Connector
+
+ from automated_ingestion.sources.san_acacia.client import DiverHubClient
+ from automated_ingestion.sources.san_acacia.dlt_pipeline import PROJECT_ID
+ from domain.units import convert_cm_to_ft
+ from domain.van_essen import parse_reading_timestamp
+
+ client = DiverHubClient(requests.Session())
+
+ point_id = args.point_id
+ if point_id is None:
+ matches = [
+ p for p in client.monitoring_points(PROJECT_ID) if p["name"] == args.well
+ ]
+ if not matches:
+ print(f"{args.well} is not a Diver-HUB monitoring point.", file=sys.stderr)
+ return 2
+ point_id = matches[0]["id"]
+
+ connector = Connector()
+ conn = connector.connect(
+ args.instance,
+ "pg8000",
+ user=_account(),
+ db=args.database,
+ enable_iam_auth=True,
+ )
+ try:
+ rows = _existing(conn.cursor(), args.well, args.samples)
+ finally:
+ conn.close()
+ connector.close()
+
+ if not rows:
+ print(f"No existing observations for {args.well}.", file=sys.stderr)
+ return 1
+
+ existing = {stamp.replace(tzinfo=None): value for stamp, value in rows}
+ start = min(existing) - timedelta(days=1)
+ end = max(existing) + timedelta(days=1)
+ print(f"{args.well} (Diver-HUB point {point_id})")
+ print(
+ f" {len(existing)} existing observations, {min(existing)} -> {max(existing)}"
+ )
+ print(
+ f" Ocotillo values: {min(existing.values()):.2f} .. {max(existing.values()):.2f} ft\n"
+ )
+
+ tolerance = timedelta(minutes=args.tolerance_minutes)
+ print(
+ f" {'reference':<12}{'vendor rows':>12}{'matched':>9}"
+ f"{'mean diff ft':>15}{'max diff ft':>14}"
+ )
+ best = None
+ for reference in REFERENCES:
+ vendor = {}
+ for row in client.water_levels(
+ point_id,
+ int(start.timestamp()),
+ int(end.timestamp()),
+ reference=reference,
+ ):
+ if row.get("level") is None:
+ continue
+ stamp = parse_reading_timestamp(row["dateAndTime"]).replace(tzinfo=None)
+ vendor[stamp] = convert_cm_to_ft(row["level"])
+
+ # Nearest within tolerance rather than exact equality. A reading logged
+ # at :45 against one recorded on the hour is the same measurement to
+ # anyone comparing datums; insisting on identical timestamps finds
+ # nothing and says nothing.
+ stamps = sorted(vendor)
+ diffs = []
+ for stamp, value in existing.items():
+ near = min(stamps, key=lambda s: abs(s - stamp)) if stamps else None
+ if near is not None and abs(near - stamp) <= tolerance:
+ diffs.append(abs(value - vendor[near]))
+
+ if not diffs:
+ print(f" reference={reference:<4}{len(vendor):>10}{'none':>11}")
+ continue
+
+ mean, worst = statistics.mean(diffs), max(diffs)
+ print(
+ f" reference={reference:<4}{len(vendor):>10}{len(diffs):>9}"
+ f"{mean:>15.3f}{worst:>14.3f}"
+ )
+ if best is None or mean < best[1]:
+ best = (reference, mean)
+
+ if best is None:
+ print("\n Nothing to compare.")
+ print(
+ " If vendor rows is 0, Diver-HUB does not retain this window for "
+ "this point -- try a well whose data runs later, or widen --tolerance-minutes."
+ )
+ return 1
+
+ reference, mean = best
+ print(f"\n Closest: reference={reference}, mean difference {mean:.3f} ft")
+ if mean < 0.05:
+ verdict = (
+ f"Ocotillo's existing data matches reference={reference}."
+ if reference == 3
+ else f"Ocotillo's existing data was loaded on reference={reference}, NOT 3."
+ )
+ else:
+ verdict = (
+ "No reference matches closely. The existing data may use a different "
+ "unit, datum or correction than any raw Diver-HUB series."
+ )
+ print(f" {verdict}")
+ return 0
+
+
+def _account() -> str:
+ import subprocess
+
+ return subprocess.run(
+ ["gcloud", "config", "get-value", "account"],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ ).stdout.strip()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/scripts/diverhub_retention.py b/automated_ingestion/scripts/diverhub_retention.py
new file mode 100644
index 000000000..0f23541de
--- /dev/null
+++ b/automated_ingestion/scripts/diverhub_retention.py
@@ -0,0 +1,121 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Find how far back Diver-HUB actually serves each monitoring point.
+
+This matters for two reasons.
+
+``INITIAL_START`` is 2015-01-01, a floor chosen before anyone knew what the
+vendor retains. A first run for a well with no history walks from there in
+windows, and every window before the vendor's earliest reading is a request that
+returns nothing -- against an endpoint that answers 500 when pushed.
+
+And the fourteen wells that already hold AMPAPI data stop in August 2022, while
+the vendor appears to start much later. If so the two datasets never overlap,
+which is why the datum comparison found nothing to compare: there is a gap
+between them, not a seam.
+
+Binary search on presence, roughly ten requests per point rather than a walk.
+
+ export DIVERHUB_USERNAME=... DIVERHUB_PASSWORD=...
+ uv run --group ingestion python -m \\
+ automated_ingestion.scripts.diverhub_retention --limit 6
+"""
+
+import argparse
+from datetime import datetime, timedelta, timezone
+
+PROBE_WINDOW = timedelta(days=30)
+
+
+def _has_data(client, point_id: int, when: datetime, reference: int) -> bool:
+ """Is there any reading in the month starting at ``when``?"""
+ rows = client.water_levels(
+ point_id,
+ int(when.timestamp()),
+ int((when + PROBE_WINDOW).timestamp()),
+ reference=reference,
+ span=int(PROBE_WINDOW.total_seconds()),
+ )
+ return any(True for _ in rows)
+
+
+def earliest_reading(
+ client, point_id: int, reference: int, floor: datetime
+) -> datetime | None:
+ """Approximate the first month that holds data, by bisection."""
+ now = datetime.now(tz=timezone.utc)
+ if not _has_data(client, point_id, now - PROBE_WINDOW, reference):
+ # Nothing recent; the point may be retired. Fall back to a wide check.
+ if not _has_data(client, point_id, floor, reference):
+ pass # keep searching regardless -- absence now proves nothing
+
+ low, high = floor, now
+ if _has_data(client, point_id, low, reference):
+ return low
+
+ # Invariant: no data at `low`, data somewhere at or before `high`.
+ for _ in range(12):
+ if (high - low) <= PROBE_WINDOW:
+ break
+ middle = low + (high - low) / 2
+ if _has_data(client, point_id, middle, reference):
+ high = middle
+ else:
+ low = middle
+ return high if _has_data(client, point_id, high - PROBE_WINDOW, reference) else high
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--limit", type=int, default=6, help="How many points to probe")
+ parser.add_argument("--floor", default="2015-01-01T00:00:00+00:00")
+ args = parser.parse_args()
+
+ import requests
+
+ from automated_ingestion.sources.san_acacia.client import (
+ GROUND_SURFACE_REFERENCE,
+ DiverHubClient,
+ )
+ from automated_ingestion.sources.san_acacia.dlt_pipeline import PROJECT_ID
+ from domain.van_essen import parse_reading_timestamp
+
+ client = DiverHubClient(requests.Session())
+ floor = parse_reading_timestamp(args.floor)
+ points = client.monitoring_points(PROJECT_ID)[: args.limit]
+
+ print(f"Probing {len(points)} of {PROJECT_ID}'s monitoring points")
+ print(f" {'point':<12}{'earliest data (approx)':>26}")
+ for point in points:
+ found = earliest_reading(client, point["id"], GROUND_SURFACE_REFERENCE, floor)
+ shown = found.date().isoformat() if found else "none found"
+ print(f" {point['name']:<12}{shown:>26}")
+
+ print(
+ "\nIf these cluster well after August 2022, the vendor and the existing\n"
+ "AMPAPI records do not overlap, and INITIAL_START can be raised to the\n"
+ "earliest date actually served -- saving a decade of empty requests on\n"
+ "every first run."
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/sources/san_acacia/dlt_pipeline.py b/automated_ingestion/sources/san_acacia/dlt_pipeline.py
index 465c8729f..5caa4c122 100644
--- a/automated_ingestion/sources/san_acacia/dlt_pipeline.py
+++ b/automated_ingestion/sources/san_acacia/dlt_pipeline.py
@@ -70,13 +70,29 @@
reliably.
"""
-INITIAL_START = "2015-01-01T00:00:00+00:00"
+INITIAL_START = "2024-01-01T00:00:00+00:00"
"""Floor for a point that has never been ingested.
-A floor, never a backfill lever: moving it forward does not delete anything
-already landed, and moving it backward does not fetch history for a point whose
-cursor has advanced past it. Use a backfill job for that
-(``BACKFILL_STRATEGY.md`` section 2).
+Diver-HUB serves nothing before late 2024. Probing six points put their earliest
+reading at 2024-10-08 and 2024-11-10, which matches the deployments on these
+wells being installed 2024-11-25 -- the vendor project was populated then.
+
+The floor sits at 2024-01-01 rather than at the earliest observed reading,
+because only six of the thirty-eight points were probed and a well with slightly
+earlier data should not be silently truncated. Nine months of margin costs one
+extra empty window; guessing too late loses real readings.
+
+It was 2015-01-01, chosen before anyone knew what the vendor retains. At a
+365-day span that made a first run walk about twelve windows per well, ten of
+them guaranteed empty, against an endpoint that answers 500 when pushed.
+
+Still a floor, never a backfill lever: lowering it will not re-fetch history for
+a series whose watermark has advanced past it (`shared/watermark.py`), and there
+is no history before 2024 to fetch.
+
+**The record has a gap.** The fourteen wells carrying AMPAPI data stop in August
+2022 and the vendor starts in late 2024, so roughly twenty-seven months are
+missing and cannot be recovered from this source.
"""
SOURCE = register(
diff --git a/docs/sources/san_acacia.md b/docs/sources/san_acacia.md
index 787670ba2..943a61018 100644
--- a/docs/sources/san_acacia.md
+++ b/docs/sources/san_acacia.md
@@ -107,6 +107,38 @@ lets it read a window without decompressing and parsing every record.
Objects written before this change are `.jsonl.gz`. dlt reads both, so they do
not need migrating, but a replay spanning that boundary reads two formats.
+
+## Retention and the gap in the record
+
+Diver-HUB serves nothing before late 2024. Probing six points put their earliest
+reading at **2024-10-08** and **2024-11-10** — matching the deployments on these
+wells, installed **2024-11-25**. The vendor project was populated then.
+
+Ocotillo already holds AMPAPI transducer data for fourteen of these wells,
+ending **2022-08-03**.
+
+**So the two sources never overlap, and roughly twenty-seven months are missing
+from the record.** That gap cannot be filled from Diver-HUB. If the divers were
+logging through it, the readings are somewhere else.
+
+Two consequences:
+
+- **The datum comparison is impossible.** Comparing the vendor's readings
+ against Ocotillo's existing values at matching timestamps was the plan for
+ confirming `reference=3` against real data. There are no matching timestamps.
+ Attempted on SO-0125 (Feb 2022) and SO-0245 (Jul–Aug 2022); the vendor
+ returned zero rows for both windows at every reference. The case for
+ `reference=3` therefore rests on the probe evidence — the elevation
+ cross-check and the 1.49 ft stickup — not on agreement with what is stored.
+- **No datum mixing can occur on a normal run.** Each series resumes from its
+ own watermark, and the vendor has nothing to return before 2024, so the two
+ bodies of data stay separate by construction rather than by care.
+
+`INITIAL_START` is 2024-01-01 as a result: nine months of margin below the
+earliest observed reading, since only six of thirty-eight points were probed.
+That takes a first run from twelve windows per well to three — 228 requests
+across all thirty-eight instead of 912.
+
## Field mapping
### Water levels — the ingested series
From 1c97aa22962ec446aa666f794e8e830aee260891 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 11:15:14 -0700
Subject: [PATCH 107/151] chore(data): drop unattributed alternate IDs from the
ingested San Acacia wells
Each of the 38 wells this pipeline reads carries two identifier links: an
NMBGMR one and an "Unknown" one with no recorded provenance. Mostly they
duplicate. Sometimes they contradict -- SO-0131 has NMBGMR "BRN-E04B (shallow)"
against Unknown "BRN-E04A" while SO-0132 has them reversed, so the two sources
disagree about which physical well is which (BDMS-1168).
Removing the unattributed rows leaves NMBGMR as the single answer. An
identifier nobody can source is worse than none, because it reads as
corroboration.
Scoped to the 38 ingested wells deliberately. The wider reach network has 152
such links and every SO- well has 263; widening is a separate decision, and 19
of the reach network's links are the conflicting ones BDMS-1168 tracks --
deleting those would remove the evidence along with the conflict.
Wells are matched by name rather than id so the migration means the same thing
in every environment, and a well missing by name is reported rather than passed
over silently.
Verified against staging: 38 wells matched, each with one NMBGMR and one
Unknown link, so it removes 38 rows and leaves every NMBGMR identifier intact.
Co-Authored-By: Claude Opus 5
---
...0260819_0001_drop_unknown_alternate_ids.py | 136 ++++++++++++++++++
1 file changed, 136 insertions(+)
create mode 100644 data_migrations/migrations/20260819_0001_drop_unknown_alternate_ids.py
diff --git a/data_migrations/migrations/20260819_0001_drop_unknown_alternate_ids.py b/data_migrations/migrations/20260819_0001_drop_unknown_alternate_ids.py
new file mode 100644
index 000000000..79b157479
--- /dev/null
+++ b/data_migrations/migrations/20260819_0001_drop_unknown_alternate_ids.py
@@ -0,0 +1,136 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Remove the unattributed `Unknown` alternate identifiers from the San Acacia
+Reach wells that the automated ingestion pipeline reads.
+
+Each of these wells carries two identifier links: an `NMBGMR` one and an
+`Unknown` one with no recorded provenance. For most wells they agree, and the
+`Unknown` row is redundant. For some they contradict each other -- `SO-0131`
+carries NMBGMR `BRN-E04B (shallow)` and Unknown `BRN-E04A`, while `SO-0132` has
+them the other way round, so the two sources disagree about which physical well
+is which (BDMS-1168).
+
+Removing the unattributed rows leaves NMBGMR as the single answer. That is the
+point: an identifier nobody can source is worse than no identifier, because it
+looks like corroboration.
+
+Scoped to the 38 wells this pipeline ingests, deliberately. The wider reach
+network has 152 such links and every `SO-` well has 263 between them; widening
+this is a separate decision, and 19 of the reach network's links are the
+conflicting ones BDMS-1168 is tracking -- deleting those would remove the
+evidence of the conflict along with the conflict.
+
+Wells are matched by name rather than by id so the migration means the same
+thing in every environment.
+"""
+
+from sqlalchemy import delete, select
+from sqlalchemy.orm import Session
+
+from data_migrations.base import DataMigration
+from db.thing import Thing, ThingIdLink
+
+UNATTRIBUTED = "Unknown"
+
+WELL_NAMES = (
+ "SO-0125",
+ "SO-0131",
+ "SO-0140",
+ "SO-0142",
+ "SO-0144",
+ "SO-0145",
+ "SO-0146",
+ "SO-0148",
+ "SO-0160",
+ "SO-0163",
+ "SO-0165",
+ "SO-0166",
+ "SO-0167",
+ "SO-0170",
+ "SO-0175",
+ "SO-0177",
+ "SO-0189",
+ "SO-0190",
+ "SO-0191",
+ "SO-0194",
+ "SO-0200",
+ "SO-0204",
+ "SO-0213",
+ "SO-0215",
+ "SO-0219",
+ "SO-0221",
+ "SO-0223",
+ "SO-0224",
+ "SO-0226",
+ "SO-0234",
+ "SO-0236",
+ "SO-0238",
+ "SO-0245",
+ "SO-0246",
+ "SO-0247",
+ "SO-0249",
+ "SO-0250",
+ "SO-0261",
+)
+
+
+def run(session: Session) -> None:
+ """Delete the unattributed links, leaving every other organization alone."""
+ thing_ids = session.scalars(
+ select(Thing.id).where(Thing.name.in_(WELL_NAMES))
+ ).all()
+
+ missing = len(WELL_NAMES) - len(thing_ids)
+ if missing:
+ # Not fatal -- a database without these wells is a database this
+ # migration has nothing to do in -- but silence would hide a rename.
+ print(
+ f" {missing} of {len(WELL_NAMES)} wells not found by name; "
+ "skipping those."
+ )
+
+ if not thing_ids:
+ return None
+
+ result = session.execute(
+ delete(ThingIdLink).where(
+ ThingIdLink.thing_id.in_(thing_ids),
+ ThingIdLink.alternate_organization == UNATTRIBUTED,
+ )
+ )
+ print(
+ f" removed {result.rowcount} {UNATTRIBUTED!r} links from {len(thing_ids)} wells"
+ )
+ return None
+
+
+MIGRATION = DataMigration(
+ id="20260819_0001_drop_unknown_alternate_ids",
+ alembic_revision="b2c3d4e5f6a7",
+ name="Drop unattributed alternate IDs from San Acacia Reach wells",
+ description=(
+ "Each ingested San Acacia well carries an NMBGMR identifier and an "
+ "unattributed 'Unknown' one. They mostly duplicate, and sometimes "
+ "contradict -- SO-0131 and SO-0132 disagree about which is BRN-E04A "
+ "(BDMS-1168). Removing the unattributed rows leaves one answer."
+ ),
+ run=run,
+ is_repeatable=False,
+)
+
+
+# ============= EOF =============================================
From c4f6dc5c3ef4cfcf296df48487f356acd8f9e4b1 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 11:23:12 -0700
Subject: [PATCH 108/151] ci(staging): report data migrations that have not
been applied
Deploys run alembic and nothing else, deliberately: a data migration changes
content rather than structure, is often irreversible -- a deletion has no
downgrade -- and may be slow enough to hold a deploy hostage. Applying one is a
decision, and data_migrations.yml is where it is made.
The cost of that choice is that a merged migration can sit unnoticed. This
closes the gap without moving the decision: the step reports and never applies.
It exits zero even with migrations pending. The deploy succeeded, and a
pipeline that goes red for something else is one people learn to ignore, so the
finding surfaces as a warning annotation and in the job summary instead. A
database it cannot reach is also a warning rather than a failure, for the same
reason.
Verified against a real database: it found one pending migration of two
registered, wrote the annotation and summary, and exited zero -- and exits zero
when the database is unreachable.
Co-Authored-By: Claude Opus 5
---
.github/workflows/CD_staging.yml | 16 +++++
scripts/__init__.py | 0
scripts/report_pending_data_migrations.py | 88 +++++++++++++++++++++++
3 files changed, 104 insertions(+)
create mode 100644 scripts/__init__.py
create mode 100644 scripts/report_pending_data_migrations.py
diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml
index 0cc960e44..b4af038bd 100644
--- a/.github/workflows/CD_staging.yml
+++ b/.github/workflows/CD_staging.yml
@@ -63,6 +63,22 @@ jobs:
run: |
uv run --no-dev alembic upgrade head
+ # Data migrations are deliberately not applied here -- they change content
+ # rather than structure, are often irreversible, and applying one is a
+ # decision made in the Data Migrations workflow. This only reports, so a
+ # merged migration cannot sit unnoticed. It never fails the deploy: the
+ # deploy worked, and a pipeline that goes red for something else is a
+ # pipeline people learn to ignore.
+ - name: Report pending data migrations
+ env:
+ DB_DRIVER: "cloudsql"
+ CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}"
+ CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}"
+ CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}"
+ CLOUD_SQL_IAM_AUTH: true
+ run: |
+ uv run --no-dev python -m scripts.report_pending_data_migrations
+
- name: Ensure envsubst is available
run: |
if ! command -v envsubst >/dev/null 2>&1; then
diff --git a/scripts/__init__.py b/scripts/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/scripts/report_pending_data_migrations.py b/scripts/report_pending_data_migrations.py
new file mode 100644
index 000000000..348456f3b
--- /dev/null
+++ b/scripts/report_pending_data_migrations.py
@@ -0,0 +1,88 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Report data migrations registered but not yet applied to this environment.
+
+Deploys run `alembic upgrade head` and nothing else, deliberately: a data
+migration changes content rather than structure, is often irreversible -- a
+deletion has no downgrade -- and may be slow enough to hold a deploy hostage.
+Applying one is a decision, and `data_migrations.yml` is where it is made.
+
+The cost of that choice is that a merged migration can sit unnoticed. This
+closes the gap without moving the decision: it reports, and never applies.
+
+Exits zero even when migrations are pending. A deploy that succeeded should not
+report failure because a separate, deliberate action has not been taken yet --
+people learn to ignore a pipeline that cries wolf. The finding surfaces as a
+GitHub warning annotation and in the job summary instead.
+"""
+
+import os
+
+
+def main() -> int:
+ from data_migrations.runner import get_status
+ from db.engine import session_ctx
+
+ try:
+ with session_ctx() as session:
+ statuses = get_status(session)
+ except Exception as exc: # noqa: BLE001 - never fail a good deploy over this
+ print(f"::warning::Could not read data migration status: {exc}")
+ return 0
+
+ pending = [s for s in statuses if s.applied_count == 0 and not s.is_repeatable]
+ applied = len(statuses) - len(pending)
+
+ summary = [
+ "## Data migrations",
+ "",
+ f"{applied} applied, **{len(pending)} pending**.",
+ "",
+ ]
+
+ if pending:
+ for status in pending:
+ print(
+ f"::warning::Data migration not applied: {status.id} "
+ f"({status.name}). Run it from the Data Migrations workflow."
+ )
+ summary += [
+ "| id | name |",
+ "| --- | --- |",
+ *[f"| `{s.id}` | {s.name} |" for s in pending],
+ "",
+ "These do **not** run on deploy. Apply them from the "
+ "**Data Migrations** workflow when you intend to.",
+ ]
+ else:
+ summary.append("Nothing pending.")
+
+ print("\n".join(summary))
+
+ path = os.environ.get("GITHUB_STEP_SUMMARY")
+ if path:
+ with open(path, "a") as handle:
+ handle.write("\n".join(summary) + "\n")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+
+# ============= EOF =============================================
From 9294f43704e3feb91cfc720cd12d987caa2b3b19 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 11:38:50 -0700
Subject: [PATCH 109/151] ci: unbind the Dagster deploy from the production
environment
The `production` GitHub environment now requires review from the deployers
team, with self-review prevented, so applying a data migration to production
takes two people. Irreversibility is the reason: a migration that deletes rows
has no downgrade, only a restore.
That environment is shared, so the gate also covers production API releases,
which is intended -- release-please already makes releasing a decision, and this
adds one approval to it.
It would also have covered the Dagster code location deploy, which is not
intended. That job reads only repository-level DAGSTER_CLOUD_API_TOKEN and
DAGSTER_CLOUD_ORGANIZATION_ID, none of the environment's secrets, and it runs on
every push to staging touching the code location. Binding it would have put an
approval gate on routine merges -- a gate on the wrong thing, since it publishes
code rather than data.
Co-Authored-By: Claude Opus 5
---
.github/workflows/CD_dagster_prod.yml | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/CD_dagster_prod.yml b/.github/workflows/CD_dagster_prod.yml
index edab044d8..5c55ffce3 100644
--- a/.github/workflows/CD_dagster_prod.yml
+++ b/.github/workflows/CD_dagster_prod.yml
@@ -47,7 +47,13 @@ concurrency:
jobs:
dagster-prod-deploy:
runs-on: ubuntu-latest
- environment: production
+ # Deliberately not bound to the `production` GitHub environment. This job
+ # reads only repository-level DAGSTER_CLOUD_API_TOKEN and
+ # DAGSTER_CLOUD_ORGANIZATION_ID -- none of that environment's secrets -- and
+ # it runs on every push to `staging` that touches the code location. Binding
+ # it would put an approval gate on routine merges once `production` requires
+ # reviewers, which is a gate on the wrong thing: this publishes code, not
+ # data.
steps:
- name: Check out source repository
From e4b1880c89d5c93369b3ed2b6469614376c9965c Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 11:41:46 -0700
Subject: [PATCH 110/151] feat(ingestion): schedule the San Acacia ingest
weekly
One job over the san_acacia asset group, selected by group so a fourth asset
joins the schedule without touching the job. Dagster orders the three steps from
their dependencies.
Weekly rather than the daily cadence the plan assumed. These are five-minute
diver readings nobody watches in real time, the vendor's endpoint answers 500
when pushed, and the watermark makes the interval a question of freshness rather
than correctness -- a run resumes from wherever the last one finished, so a
missed week is caught up rather than lost.
Mondays 05:00 America/Denver rather than UTC: the wells, the people reading the
data and the working day are all in one timezone, so a schedule that shifts an
hour twice a year would be the surprising choice.
Stopped by default. Turning it on begins writing to Ocotillo, and the first run
for the 24 wells without history fetches back to the floor. That is a decision
somebody should take once, not a consequence of a merge.
A test pins the selection to exactly the three ingest assets. A group name is a
string, and a typo would leave a schedule that runs successfully and ingests
nothing, which looks like everything working.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/defs/definitions.py | 6 ++
automated_ingestion/defs/jobs/san_acacia.py | 80 +++++++++++++++++++++
automated_ingestion/tests/test_schedule.py | 74 +++++++++++++++++++
docs/automated-ingestion-pipeline-plan.md | 13 ++--
4 files changed, 168 insertions(+), 5 deletions(-)
create mode 100644 automated_ingestion/defs/jobs/san_acacia.py
create mode 100644 automated_ingestion/tests/test_schedule.py
diff --git a/automated_ingestion/defs/definitions.py b/automated_ingestion/defs/definitions.py
index aabacc42a..3d4bd6c72 100644
--- a/automated_ingestion/defs/definitions.py
+++ b/automated_ingestion/defs/definitions.py
@@ -24,10 +24,16 @@
from dagster import Definitions
from automated_ingestion.defs.assets import all_assets
+from automated_ingestion.defs.jobs.san_acacia import (
+ san_acacia_job,
+ san_acacia_weekly_schedule,
+)
from automated_ingestion.defs.resources import OcotilloDatabase
defs = Definitions(
assets=all_assets(),
+ jobs=[san_acacia_job],
+ schedules=[san_acacia_weekly_schedule],
resources={"database": OcotilloDatabase()},
)
diff --git a/automated_ingestion/defs/jobs/san_acacia.py b/automated_ingestion/defs/jobs/san_acacia.py
new file mode 100644
index 000000000..d38c1c35c
--- /dev/null
+++ b/automated_ingestion/defs/jobs/san_acacia.py
@@ -0,0 +1,80 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+The scheduled run for San Acacia Reach.
+
+One job over the whole `san_acacia` asset group, so the three steps stay in
+order: land the point roster, land the readings, then map and load them. Dagster
+resolves that from the asset dependencies rather than from anything declared
+here, which is why the selection is by group -- a fourth asset added to the
+group joins the schedule without this file changing.
+
+Weekly rather than daily. These are five-minute diver readings and nobody is
+watching them in real time; the vendor's endpoint answers 500 when pushed, and a
+weekly cadence keeps each run's windows comfortably inside what it serves. The
+watermark makes the interval a matter of freshness rather than correctness: a
+run fetches from wherever the last one finished, so a missed week is picked up
+by the next run rather than lost.
+"""
+
+from dagster import (
+ AssetSelection,
+ DefaultScheduleStatus,
+ RetryPolicy,
+ ScheduleDefinition,
+ define_asset_job,
+)
+
+SAN_ACACIA_GROUP = "san_acacia"
+
+san_acacia_job = define_asset_job(
+ name="san_acacia_ingest",
+ selection=AssetSelection.groups(SAN_ACACIA_GROUP),
+ description=(
+ "Land the San Acacia point roster and readings in the raw zone, then "
+ "map and load them into Ocotillo."
+ ),
+ # A retry covers the vendor dropping a request or a token expiring mid-run.
+ # Two attempts, not more: a persistent 500 means the window is wrong or the
+ # endpoint is unwell, and hammering it makes both worse.
+ op_retry_policy=RetryPolicy(max_retries=2, delay=60),
+)
+
+san_acacia_weekly_schedule = ScheduleDefinition(
+ name="san_acacia_weekly",
+ job=san_acacia_job,
+ # Mondays at 05:00 America/Denver -- after midnight so a run covers whole
+ # days, and early enough that a failure is visible at the start of the week
+ # rather than discovered the following Monday.
+ cron_schedule="0 5 * * 1",
+ execution_timezone="America/Denver",
+ # Local time rather than UTC deliberately: the wells, the people who read
+ # the data, and the working day are all in one timezone, so a schedule that
+ # shifts by an hour twice a year would be the surprising choice.
+ #
+ # Stopped by default. Turning it on starts writing to Ocotillo, and the
+ # first run for the 24 wells without history fetches back to the
+ # `INITIAL_START` floor. That should be somebody's decision, taken once,
+ # rather than a consequence of a merge.
+ default_status=DefaultScheduleStatus.STOPPED,
+ description=(
+ "Weekly San Acacia ingest. Each run resumes from each series' "
+ "watermark, so a missed week is caught up rather than lost."
+ ),
+)
+
+
+# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_schedule.py b/automated_ingestion/tests/test_schedule.py
new file mode 100644
index 000000000..4b2000e87
--- /dev/null
+++ b/automated_ingestion/tests/test_schedule.py
@@ -0,0 +1,74 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+The weekly schedule selects what it claims to.
+
+A group name is a string, so a typo yields a schedule that runs successfully and
+ingests nothing -- which looks like everything is fine.
+"""
+
+from dagster import DefaultScheduleStatus
+
+from automated_ingestion.defs.definitions import defs
+
+EXPECTED = {
+ "raw_san_acacia_locations",
+ "raw_san_acacia_readings",
+ "san_acacia_observations",
+}
+
+
+def _schedule():
+ return next(s for s in defs.schedules if s.name == "san_acacia_weekly")
+
+
+def test_the_schedule_is_registered():
+ assert _schedule().job.name == "san_acacia_ingest"
+
+
+def test_it_selects_every_san_acacia_asset_and_nothing_else():
+ selected = {
+ key.to_user_string()
+ for key in _schedule().job.selection.resolve(list(defs.assets))
+ }
+ assert selected == EXPECTED
+
+
+def test_operations_assets_are_excluded():
+ # ingestion_heartbeat and database_connectivity are diagnostics. Running
+ # them weekly would add noise and, for connectivity, a pointless query.
+ selected = {
+ key.to_user_string()
+ for key in _schedule().job.selection.resolve(list(defs.assets))
+ }
+ assert "ingestion_heartbeat" not in selected
+ assert "database_connectivity" not in selected
+
+
+def test_it_runs_weekly_in_local_time():
+ schedule = _schedule()
+ assert schedule.cron_schedule == "0 5 * * 1"
+ assert schedule.execution_timezone == "America/Denver"
+
+
+def test_it_is_stopped_until_somebody_starts_it():
+ # Turning it on begins writing to Ocotillo, and the first run for the wells
+ # without history fetches back to the floor. That is a decision, not a
+ # consequence of a merge.
+ assert _schedule().default_status is DefaultScheduleStatus.STOPPED
+
+
+# ============= EOF =============================================
diff --git a/docs/automated-ingestion-pipeline-plan.md b/docs/automated-ingestion-pipeline-plan.md
index 87a1349ab..5eaffdab5 100644
--- a/docs/automated-ingestion-pipeline-plan.md
+++ b/docs/automated-ingestion-pipeline-plan.md
@@ -419,11 +419,14 @@ Covers raw already in GCS with only the mapping wrong: adapter or unit bug, newl
### 4.4 — Schedule, observability, alerting
-- `san_acacia_schedule` runs the daily pipeline; cron avoids contention with existing Dagster+ jobs in the org, recorded in the source registry.
-- Dagster logs bridge into the repo's existing logging setup, so ingestion failures surface where the team already looks. Confirm which error-tracking destination is current before wiring this — do not assume the repo's existing integrations are live.
-- A failed run notifies someone — not discovered via a stale hydrograph.
-- Every run emits rows ingested, rows upserted, entities processed, entities failed, adapter failures, resulting watermark per series.
-- A zero-new-rows run succeeds and is distinguishable in the logs from a failure.
+Schedule built. `defs/jobs/san_acacia.py` — `san_acacia_ingest` over the whole `san_acacia` asset group, on `san_acacia_weekly`.
+
+- ✅ **Weekly, not daily.** These are five-minute diver readings nobody watches in real time, the vendor's endpoint answers 500 when pushed, and the watermark makes the interval a question of freshness rather than correctness — a missed week is caught up by the next run, not lost.
+- ✅ Mondays 05:00 **America/Denver**, not UTC. The wells, the people reading the data and the working day are in one timezone; a schedule drifting an hour twice a year would be the surprising choice. After midnight so a run covers whole days, early enough that a failure is visible at the start of the week.
+- ✅ Selected **by group**, so an asset added to `san_acacia` joins the schedule without touching the job. A test asserts the selection resolves to exactly the three ingest assets and excludes `ingestion_heartbeat` and `database_connectivity` — a group-name typo would otherwise produce a schedule that runs happily and ingests nothing.
+- ✅ `RetryPolicy(max_retries=2, delay=60)` covers a dropped request or a token expiring mid-run. Two attempts, not more: a persistent 500 means the window is wrong or the endpoint is unwell, and hammering it makes both worse.
+- ✅ **`DefaultScheduleStatus.STOPPED`.** Turning it on starts writing to Ocotillo, and the first run for the 24 wells without history fetches back to `INITIAL_START`. That should be a decision taken once, not a consequence of a merge.
+- ⬜ Observability and alerting — log bridge, failure notification, run metadata.
### 4.5 — Documentation
From de296fdc554bcd70e43bc919a4b9e53c05eab2ec Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 12:17:27 -0700
Subject: [PATCH 111/151] test(ingestion): cover the Dagster assets
ingest.py was at 16%. Every part of san_acacia_observations was covered on its
own -- matching, resolving, watermarks, the adapter, the loader -- but the
orchestration between them was not, which is where the decisions live: which
wells get skipped, what the metadata reports, and whether one unresolvable well
costs the others.
Now 87%, and automated_ingestion overall 83% to 88%.
Fakes stand in at the process boundaries only -- the vendor client, the database
session, the dlt pipeline. The reconciler, resolver and adapter run for real, so
a change in their behaviour surfaces here rather than being absorbed by a mock.
Covered: a well with no match is skipped rather than invented; two wells sharing
a name are skipped rather than picked between; a well with no open transducer,
and one whose transducer was removed, are skipped; a refused reading is counted
rather than vanishing; one bad well does not cost the others; both raw assets
write parquet; a point the vendor refuses is counted and reported.
What remains uncovered is _client and the two SQLAlchemy query helpers. They
are thin wrappers around a live database and a real HTTP session, and testing
them would mean asserting that mocks were called.
Co-Authored-By: Claude Opus 5
---
.../tests/test_ingest_assets.py | 328 ++++++++++++++++++
1 file changed, 328 insertions(+)
create mode 100644 automated_ingestion/tests/test_ingest_assets.py
diff --git a/automated_ingestion/tests/test_ingest_assets.py b/automated_ingestion/tests/test_ingest_assets.py
new file mode 100644
index 000000000..7170fd9b3
--- /dev/null
+++ b/automated_ingestion/tests/test_ingest_assets.py
@@ -0,0 +1,328 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+The Dagster assets, which is where the pieces meet.
+
+Every part of `san_acacia_observations` is covered on its own -- matching,
+resolving, watermarks, the adapter, the loader. What was not covered is the
+orchestration between them: which wells get skipped, what the metadata says, and
+whether one unresolvable well costs the others.
+
+Fakes stand in at the process boundaries -- the vendor client, the database
+session, the dlt pipeline -- and nowhere else. The reconciler, resolver and
+adapter run for real, so a change in their behaviour shows up here.
+"""
+
+from contextlib import contextmanager
+from datetime import date
+
+import pytest
+from dagster import build_asset_context
+
+from automated_ingestion.ocotillo import loader as loader_module
+from automated_ingestion.ocotillo.loader import LoadResult
+from automated_ingestion.shared import watermark as watermark_module
+from automated_ingestion.sources.san_acacia import ingest
+from automated_ingestion.sources.san_acacia.reconcile import ThingCandidate
+from automated_ingestion.sources.san_acacia.resolve import DeploymentCandidate
+
+READING = {"dateAndTime": "2026-04-15T22:45:00", "level": 471.518}
+
+
+class FakeClient:
+ """The vendor, reduced to what the assets ask of it."""
+
+ def __init__(self, points, readings=None, approved=()):
+ self._points = points
+ self._readings = READING if readings is None else readings
+ self._approved = approved
+ self.water_level_calls = []
+
+ def monitoring_points(self, project_id):
+ return self._points
+
+ def water_levels(self, point_id, start, end, reference, approved=None, span=None):
+ self.water_level_calls.append((point_id, start, end, approved))
+ if approved:
+ return iter(self._approved)
+ return iter(
+ self._readings if isinstance(self._readings, list) else [self._readings]
+ )
+
+
+class FakeDatabase:
+ """Stands in for OcotilloDatabase. The session is never really used --
+ every function that would touch it is replaced."""
+
+ @contextmanager
+ def session(self):
+ yield object()
+
+
+class NoWatermark:
+ def __init__(self, session):
+ pass
+
+ def get(self, thing_id, parameter_id):
+ return None
+
+
+@pytest.fixture()
+def wired(monkeypatch):
+ """Wire the asset to fakes, returning the recorded loads."""
+ loaded = []
+
+ def fake_load(session, records, deployment_id, parameter_id, release_status, **kw):
+ records = list(records)
+ loaded.append(
+ {
+ "deployment_id": deployment_id,
+ "parameter_id": parameter_id,
+ "release_status": release_status,
+ "rows": len(records),
+ }
+ )
+ return LoadResult(rows_seen=len(records), rows_written=len(records), batches=1)
+
+ monkeypatch.setattr(loader_module, "load_observations", fake_load)
+ monkeypatch.setattr(loader_module, "ensure_block", lambda *a, **k: 1)
+ monkeypatch.setattr(watermark_module, "PostgresWatermarkStore", NoWatermark)
+ monkeypatch.setattr(ingest, "_parameter_id", lambda session, name: 1)
+ return loaded
+
+
+def _run(monkeypatch, client, candidates, deployments):
+ monkeypatch.setattr(ingest, "_client", lambda: client)
+ monkeypatch.setattr(ingest, "_well_candidates", lambda session: candidates)
+ monkeypatch.setattr(ingest, "_deployments", lambda session, thing_id: deployments)
+ return ingest.san_acacia_observations(build_asset_context(), FakeDatabase())
+
+
+TRANSDUCER = DeploymentCandidate(437, "Pressure Transducer")
+
+
+class TestObservationsAsset:
+ def test_a_resolvable_well_is_loaded(self, monkeypatch, wired):
+ output = _run(
+ monkeypatch,
+ FakeClient([{"id": 39, "name": "SO-0125"}]),
+ [ThingCandidate(2343, "SO-0125")],
+ [TRANSDUCER],
+ )
+ assert output.value == 1
+ assert wired[0]["deployment_id"] == 437
+ assert wired[0]["release_status"] == "public"
+ assert output.metadata["wells_skipped"].value == 0
+
+ def test_an_unmatched_well_is_skipped_not_invented(self, monkeypatch, wired):
+ # No Ocotillo well by that name. Ingestion does not create wells.
+ output = _run(
+ monkeypatch,
+ FakeClient([{"id": 39, "name": "SO-9999"}]),
+ [ThingCandidate(2343, "SO-0125")],
+ [TRANSDUCER],
+ )
+ assert output.value == 0
+ assert wired == []
+ assert output.metadata["wells_skipped"].value == 1
+ assert "unmatched" in str(output.metadata["skipped"].data)
+
+ def test_an_ambiguous_well_is_skipped(self, monkeypatch, wired):
+ # Two wells share the name -- picking one would be a silent guess.
+ output = _run(
+ monkeypatch,
+ FakeClient([{"id": 39, "name": "SO-0125"}]),
+ [ThingCandidate(1, "SO-0125"), ThingCandidate(2, "SO-0125")],
+ [TRANSDUCER],
+ )
+ assert wired == []
+ assert "ambiguous" in str(output.metadata["skipped"].data)
+
+ def test_a_well_without_a_transducer_is_skipped(self, monkeypatch, wired):
+ # SO-0246 is in this state in production.
+ output = _run(
+ monkeypatch,
+ FakeClient([{"id": 39, "name": "SO-0125"}]),
+ [ThingCandidate(2343, "SO-0125")],
+ [DeploymentCandidate(436, "DiverLink")],
+ )
+ assert wired == []
+ assert "missing" in str(output.metadata["skipped"].data)
+
+ def test_a_removed_transducer_does_not_qualify(self, monkeypatch, wired):
+ output = _run(
+ monkeypatch,
+ FakeClient([{"id": 39, "name": "SO-0125"}]),
+ [ThingCandidate(2343, "SO-0125")],
+ [
+ DeploymentCandidate(
+ 437, "Pressure Transducer", removal_date=date(2024, 1, 1)
+ )
+ ],
+ )
+ assert wired == []
+
+ def test_one_bad_well_does_not_cost_the_others(self, monkeypatch, wired):
+ # The point of skipping rather than raising.
+ output = _run(
+ monkeypatch,
+ FakeClient(
+ [
+ {"id": 39, "name": "SO-9999"},
+ {"id": 40, "name": "SO-0125"},
+ ]
+ ),
+ [ThingCandidate(2343, "SO-0125")],
+ [TRANSDUCER],
+ )
+ assert output.value == 1
+ assert output.metadata["wells_attempted"].value == 2
+ assert output.metadata["wells_skipped"].value == 1
+
+ def test_a_reading_the_adapter_refuses_is_counted(self, monkeypatch, wired):
+ # A null level has nothing to store; it should surface, not vanish.
+ client = FakeClient(
+ [{"id": 39, "name": "SO-0125"}],
+ readings=[{"dateAndTime": "2026-04-15T22:45:00", "level": None}],
+ )
+ output = _run(
+ monkeypatch, client, [ThingCandidate(2343, "SO-0125")], [TRANSDUCER]
+ )
+ assert output.value == 0
+ assert output.metadata["adapter_failures"].value == 1
+
+ def test_no_wells_at_all(self, monkeypatch, wired):
+ output = _run(monkeypatch, FakeClient([]), [], [TRANSDUCER])
+ assert output.value == 0
+ assert output.metadata["wells_attempted"].value == 0
+
+
+class TestParameterLookup:
+ def test_a_missing_parameter_is_a_clear_error(self):
+ # Ingestion does not create parameters, so the message has to say what
+ # to do instead of surfacing an integrity error later.
+ class Empty:
+ def scalar(self, *_):
+ return None
+
+ with pytest.raises(RuntimeError, match="does not create parameters"):
+ ingest._parameter_id(Empty(), "groundwater level")
+
+ def test_a_found_parameter_is_returned(self):
+ class Found:
+ def scalar(self, *_):
+ return 7
+
+ assert ingest._parameter_id(Found(), "groundwater level") == 7
+
+
+class TestRowCount:
+ def test_malformed_load_info_reports_zero(self):
+ # Metadata must never fail a load that worked.
+ assert ingest._row_count(object()) == 0
+
+ def test_none_reports_zero(self):
+ assert ingest._row_count(None) == 0
+
+
+class FakePipeline:
+ """Stands in for the dlt pipeline. Records what it was asked to run."""
+
+ def __init__(self):
+ self.runs = []
+
+ def run(self, resource, loader_file_format=None):
+ # Consume the resource so the generator body actually executes.
+ rows = list(resource) if hasattr(resource, "__iter__") else []
+ self.runs.append({"format": loader_file_format, "rows": len(rows)})
+ return object()
+
+
+class TestRawAssets:
+ def test_locations_reports_what_it_landed(self, monkeypatch):
+ from automated_ingestion.sources.san_acacia import dlt_pipeline
+
+ pipeline = FakePipeline()
+ client = FakeClient(
+ [{"id": 39, "name": "SO-0125"}, {"id": 40, "name": "SO-0131"}]
+ )
+ monkeypatch.setattr(ingest, "_client", lambda: client)
+ monkeypatch.setattr(dlt_pipeline, "build_pipeline", lambda: pipeline)
+
+ output = ingest.raw_san_acacia_locations(build_asset_context())
+
+ assert output.value == 2
+ assert output.metadata["monitoring_points"].value == 2
+ assert "SO-0125" in output.metadata["names"].value
+
+ def test_locations_are_written_as_parquet(self, monkeypatch):
+ # dlt writes gzipped JSONL unless told otherwise, and Mode B replay
+ # assumes parquet.
+ from automated_ingestion.sources.san_acacia import dlt_pipeline
+
+ pipeline = FakePipeline()
+ monkeypatch.setattr(ingest, "_client", lambda: FakeClient([]))
+ monkeypatch.setattr(dlt_pipeline, "build_pipeline", lambda: pipeline)
+
+ ingest.raw_san_acacia_locations(build_asset_context())
+ assert pipeline.runs[0]["format"] == "parquet"
+
+ def test_readings_report_per_point_failures(self, monkeypatch):
+ from automated_ingestion.sources.san_acacia import dlt_pipeline
+
+ pipeline = FakePipeline()
+ client = FakeClient([{"id": 39, "name": "SO-0125"}])
+ monkeypatch.setattr(ingest, "_client", lambda: client)
+ monkeypatch.setattr(dlt_pipeline, "build_pipeline", lambda: pipeline)
+
+ output = ingest.raw_san_acacia_readings(build_asset_context())
+
+ assert output.metadata["points_attempted"].value == 1
+ assert output.metadata["points_failed"].value == 0
+
+ def test_readings_are_written_as_parquet(self, monkeypatch):
+ from automated_ingestion.sources.san_acacia import dlt_pipeline
+
+ pipeline = FakePipeline()
+ monkeypatch.setattr(ingest, "_client", lambda: FakeClient([]))
+ monkeypatch.setattr(dlt_pipeline, "build_pipeline", lambda: pipeline)
+
+ ingest.raw_san_acacia_readings(build_asset_context())
+ assert pipeline.runs[0]["format"] == "parquet"
+
+ def test_readings_count_a_point_the_vendor_refuses(self, monkeypatch):
+ # One diver failing must cost that diver, not the run. The count is how
+ # anyone finds out it happened.
+ from automated_ingestion.sources.san_acacia import dlt_pipeline
+ from automated_ingestion.sources.san_acacia.client import DiverHubError
+
+ class Refusing(FakeClient):
+ def water_levels(self, *a, **kw):
+ raise DiverHubError("500 at the minimum window")
+
+ pipeline = FakePipeline()
+ client = Refusing([{"id": 39, "name": "SO-0125"}])
+ monkeypatch.setattr(ingest, "_client", lambda: client)
+ monkeypatch.setattr(dlt_pipeline, "build_pipeline", lambda: pipeline)
+
+ output = ingest.raw_san_acacia_readings(build_asset_context())
+
+ assert output.metadata["points_failed"].value == 1
+ assert output.metadata["points_attempted"].value == 1
+ assert "500" in str(output.metadata["failures"].data)
+
+
+# ============= EOF =============================================
From 1405ce9c05c75df7c0e4c6cf288249d02c3b58a8 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 13:12:57 -0700
Subject: [PATCH 112/151] fix(ingestion): stop duplicate instants reaching one
INSERT
The first live materialization failed:
ON CONFLICT DO UPDATE command cannot affect row a second time
HINT: Ensure that no rows proposed for insertion within the same command
have duplicate constrained values.
Two causes, both here. Diver-HUB ranges are inclusive at both ends -- "up to
and including end time" -- while iter_windows made adjacent windows share a
boundary, so a reading logged exactly on it came back in both. And the loader
never deduplicated, so that pair reached Postgres in one statement.
Windows now leave exactly one second between them. Timestamps are
second-resolution, so nothing falls in the gap.
The loader also deduplicates within a batch, keeping the last occurrence --
which matches the upsert's own rule that a later value wins. That guard holds
whatever the source does, including a vendor logging one instant twice.
The existing idempotency test could not have caught this: it loads the same
window in two separate statements, which Postgres allows. The new test puts the
duplicates in one batch, which is what actually happened.
Co-Authored-By: Claude Opus 5
---
automated_ingestion/ocotillo/loader.py | 13 +++++++--
automated_ingestion/shared/windows.py | 11 ++++++--
automated_ingestion/tests/test_windows.py | 18 ++++++++++---
tests/test_transducer_loader.py | 32 +++++++++++++++++++++++
4 files changed, 67 insertions(+), 7 deletions(-)
diff --git a/automated_ingestion/ocotillo/loader.py b/automated_ingestion/ocotillo/loader.py
index 7b831bf82..cabafccf5 100644
--- a/automated_ingestion/ocotillo/loader.py
+++ b/automated_ingestion/ocotillo/loader.py
@@ -119,6 +119,14 @@ def load_observations(
table = TransducerObservation.__table__
for batch in _batched(records, batch_size):
+ result.rows_seen += len(batch)
+
+ # One row per instant within a statement. Postgres refuses an
+ # ON CONFLICT DO UPDATE that would touch the same row twice in one
+ # command, and a source can repeat a reading -- overlapping fetch
+ # windows, or a vendor logging the same instant twice. Keeping the last
+ # occurrence matches the upsert's own rule: a later value wins.
+ deduplicated = {record.observation_datetime: record for record in batch}
rows = [
{
"deployment_id": deployment_id,
@@ -128,9 +136,10 @@ def load_observations(
"release_status": release_status,
"data_maturity": data_maturity,
}
- for record in batch
+ for record in deduplicated.values()
]
- result.rows_seen += len(rows)
+ if not rows:
+ continue
statement = insert(table).values(rows)
# DO UPDATE rather than DO NOTHING: a vendor may correct a reading, and
diff --git a/automated_ingestion/shared/windows.py b/automated_ingestion/shared/windows.py
index 9fc8765d6..039eff232 100644
--- a/automated_ingestion/shared/windows.py
+++ b/automated_ingestion/shared/windows.py
@@ -74,10 +74,17 @@ def iter_windows(start: int, end: int, span: int = DEFAULT_SPAN) -> Iterator[Win
raise ValueError(f"Window span must be positive, got {span}.")
if end < start:
raise ValueError(f"End {end} precedes start {start}.")
+ # Windows must not share a boundary. Diver-HUB's ranges are inclusive at
+ # both ends -- "from start time up to and including end time" -- so
+ # [0, span] and [span, 2*span] both return the reading logged exactly at
+ # `span`. That duplicate reaches the loader in one batch and Postgres
+ # rejects the statement: "ON CONFLICT DO UPDATE command cannot affect row a
+ # second time".
cursor = start
while cursor < end:
- yield Window(cursor, min(cursor + span, end))
- cursor += span
+ chunk_end = min(cursor + span, end)
+ yield Window(cursor, chunk_end)
+ cursor = chunk_end + 1
# ============= EOF =============================================
diff --git a/automated_ingestion/tests/test_windows.py b/automated_ingestion/tests/test_windows.py
index 34d68602d..d4d9cf2ab 100644
--- a/automated_ingestion/tests/test_windows.py
+++ b/automated_ingestion/tests/test_windows.py
@@ -25,12 +25,24 @@
)
-def test_windows_cover_the_range_without_gaps_or_overlap():
+def test_windows_do_not_share_a_boundary():
+ # Diver-HUB ranges are inclusive at both ends, so touching windows both
+ # return the reading logged exactly on the boundary. That duplicate reaches
+ # the loader in one batch and Postgres rejects the statement: "ON CONFLICT
+ # DO UPDATE command cannot affect row a second time".
windows = list(iter_windows(0, 10 * DAY, span=3 * DAY))
assert windows[0].start == 0
assert windows[-1].end == 10 * DAY
for earlier, later in zip(windows, windows[1:]):
- assert earlier.end == later.start
+ assert later.start == earlier.end + 1
+
+
+def test_windows_leave_no_second_uncovered():
+ # The gap is exactly one second and timestamps are second-resolution, so
+ # nothing can fall between two windows.
+ windows = list(iter_windows(0, 10 * DAY, span=3 * DAY))
+ for earlier, later in zip(windows, windows[1:]):
+ assert later.start - earlier.end == 1
def test_final_window_is_truncated_not_overshot():
@@ -38,7 +50,7 @@ def test_final_window_is_truncated_not_overshot():
# and at worst a 400.
windows = list(iter_windows(0, 10 * DAY, span=3 * DAY))
assert windows[-1].end == 10 * DAY
- assert windows[-1].span == DAY
+ assert all(w.end <= 10 * DAY for w in windows)
def test_range_shorter_than_span_is_a_single_window():
diff --git a/tests/test_transducer_loader.py b/tests/test_transducer_loader.py
index 8913713da..d9a42d42a 100644
--- a/tests/test_transducer_loader.py
+++ b/tests/test_transducer_loader.py
@@ -268,4 +268,36 @@ def test_rows_with_no_recorded_maturity_still_update(loader_target):
assert value == 99.0
+def test_duplicate_instants_in_one_batch_do_not_break_the_statement(loader_target):
+ """Reproduces the first live run's failure.
+
+ Postgres rejects an ON CONFLICT DO UPDATE that would touch the same row
+ twice in one command:
+
+ ON CONFLICT DO UPDATE command cannot affect row a second time
+
+ The existing idempotency test loads the same window in two separate
+ statements, which Postgres allows, so it could not catch this. A source can
+ repeat an instant -- overlapping fetch windows did, and a vendor may log one
+ twice.
+ """
+ deployment_id, parameter_id = loader_target
+ duplicated = _records(3) + _records(3, value=99.0)
+
+ with session_ctx() as session:
+ result = load_observations(
+ session, duplicated, deployment_id, parameter_id, "draft"
+ )
+ assert _count(session, deployment_id) == 3
+ assert result.rows_seen == 6
+
+ # The later value wins, matching the upsert's own rule.
+ values = session.scalars(
+ select(TransducerObservation.value)
+ .where(TransducerObservation.deployment_id == deployment_id)
+ .order_by(TransducerObservation.observation_datetime)
+ ).all()
+ assert values[0] == 99.0
+
+
# ============= EOF =============================================
From 4a0da4248c9075e765a219b1efb66ec48366ffe1 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 13:47:46 -0700
Subject: [PATCH 113/151] ci(dagster): deploy code location as PEX instead of
an image
Both Dagster+ CD workflows built and pushed a container image to ECR on
every run, which dominated deploy time even when only ingestion source
changed. Serverless deploys can instead publish two PEX files -- deps.pex
and source.pex -- and reuse a cached deps.pex when the resolved
requirements are unchanged, so a source-only change uploads ~9MB rather
than rebuilding a full image.
Each workflow now runs actions/utils/prerun, which reports pex-deploy or
docker-deploy from ENABLE_FAST_DEPLOYS. The PEX path is the default; the
old image build survives verbatim in a second job reached by setting that
variable to 'false', as the escape hatch for anything PEX cannot express
(a dependency with no Linux wheel that also fails to build from source, a
system package needing apt). Every dependency currently resolves to a
linux/cp313 wheel, so neither case applies today.
Two details are load-bearing:
`--no-hashes` on the PEX export, and only there. The PEX builder unions
requirements.txt with [project].dependencies from pyproject.toml, and
those pins carry no hashes -- a hashed requirements.txt would put pip in
--require-hashes mode, where every unhashed line is a hard error. Both
sources resolve from the same uv.lock, so the duplicate pins agree. The
Docker path keeps hashes; it feeds the file straight to pip install -r
with nothing unhashed mixed in.
python_version 3.13, against an action default of 3.8. The PEX files are
resolved for one interpreter, and requires-python is >= 3.13.
On the branch workflow, prerun also handles the closed-PR teardown: it
runs `ci branch-deployment` to mark the deployment closed and reports
skip, so the docker action's own closed-PR branch is no longer reached.
dagster_cloud_post_install.sh is unchanged apart from its header. Only
the Docker path still runs it; the source-pex builder performs the same
`uv pip install --no-deps .` of this repository itself, which is what
keeps db/ and domain/ importable from the process that executes a step.
Co-Authored-By: Claude Opus 5
---
.github/workflows/CD_dagster_branch.yml | 152 +++++++++++++++++++++---
.github/workflows/CD_dagster_prod.yml | 138 +++++++++++++++++++--
dagster_cloud_post_install.sh | 8 +-
3 files changed, 272 insertions(+), 26 deletions(-)
diff --git a/.github/workflows/CD_dagster_branch.yml b/.github/workflows/CD_dagster_branch.yml
index 69394860a..7c0928788 100644
--- a/.github/workflows/CD_dagster_branch.yml
+++ b/.github/workflows/CD_dagster_branch.yml
@@ -3,6 +3,21 @@
#
# Path-filtered: most PRs in this repository touch only the API and should not
# create a Dagster+ deployment at all.
+#
+# ## Two deploy paths
+#
+# The default path builds no container image at all: it packages the
+# dependencies and the source into two PEX files and uploads them, so a source
+# change reuses the previously published deps.pex instead of rebuilding and
+# pushing several hundred megabytes to ECR. See
+# https://dagster.io/blog/fast-deploys-with-pex-and-docker. The deps.pex cache
+# is keyed per repository, not per deployment, so a PR that changes no
+# dependency reuses the one the last prod deploy published.
+#
+# Setting ENABLE_FAST_DEPLOYS to 'false' below switches the whole workflow back
+# to the Docker image build in the `dagster-branch-docker-deploy` job. Keep both
+# paths working, and keep this file's setting in step with CD_dagster_prod.yml --
+# the two share the deps.pex cache, and a PR built the other way just misses it.
name: CD (Dagster+ branch deployment)
on:
@@ -32,6 +47,22 @@ concurrency:
group: dagster-branch-deploy-${{ github.event.pull_request.number }}
cancel-in-progress: true
+env:
+ # The PEX path targets `$DAGSTER_CLOUD_URL/`, so this is the
+ # organization URL with no deployment path appended. The Docker path takes the
+ # organization id as an action input instead and ignores this.
+ DAGSTER_CLOUD_URL: https://${{ vars.DAGSTER_CLOUD_ORGANIZATION_ID }}.dagster.cloud
+ DAGSTER_CLOUD_API_TOKEN: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+ # Read by actions/utils/prerun, which reports `pex-deploy` or `docker-deploy`
+ # and so decides which of the two jobs below runs.
+ ENABLE_FAST_DEPLOYS: "true"
+ # The PEX files are resolved for this interpreter only, and requires-python is
+ # >= 3.13. Anything lower makes the deps resolve fail with "no matching
+ # distribution", which reads like a broken requirements file rather than a
+ # version mismatch.
+ PYTHON_VERSION: "3.13"
+ DAGSTER_CLOUD_FILE: dagster_cloud.yaml
+
jobs:
dagster-branch-deploy:
runs-on: ubuntu-latest
@@ -39,32 +70,125 @@ jobs:
# untrusted fork would run our code against our infrastructure regardless.
if: github.event.pull_request.head.repo.full_name == github.repository
- # The action's notify steps post build status as a PR comment and read the
- # token from the workflow environment -- `env.GITHUB_TOKEN`, not the
- # `secrets` context. Without this the run dies on an empty-token assertion
- # before it ever reaches Dagster+, which reads as an auth failure but is
- # not one.
+ # The notify steps post build status as a PR comment and read the token from
+ # the workflow environment -- `env.GITHUB_TOKEN`, not the `secrets` context.
+ # Without this the run dies on an empty-token assertion before it ever
+ # reaches Dagster+, which reads as an auth failure but is not one.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ outputs:
+ # Set only on the Docker path. The fallback job keys off it being empty.
+ build_info: ${{ steps.parse.outputs.build_info }}
+
steps:
- - name: Check out source repository
- uses: actions/checkout@v7.0.1
+ # Two jobs in one step. On a `closed` event it runs `ci
+ # branch-deployment`, which marks the branch deployment closed so stale
+ # deployments do not accumulate, and reports `skip` -- both deploy paths
+ # then do nothing. Otherwise it reads ENABLE_FAST_DEPLOYS and reports
+ # `pex-deploy` or `docker-deploy`.
+ #
+ # Its own checkout goes to prerun_checkout_dir, so it does not disturb the
+ # working tree either path sets up below.
+ - name: Prerun checks
+ id: prerun
+ uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v1.13.18
# parse_workspace performs its own `actions/checkout`, which cleans the
- # working tree. It has to run *before* requirements.txt is generated, or
- # the generated file is deleted before the deploy step can use it.
+ # working tree -- fine here because the Docker path runs in a separate job
+ # that checks out again. Only its `build_info` output crosses the boundary.
- name: Parse dagster_cloud.yaml
+ if: steps.prerun.outputs.result == 'docker-deploy'
id: parse
uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v1.13.18
with:
- dagster_cloud_file: dagster_cloud.yaml
+ dagster_cloud_file: ${{ env.DAGSTER_CLOUD_FILE }}
+
+ # Checked out under a subdirectory because build_deploy_python_executable
+ # takes an absolute path to the location file and does not check out
+ # anything itself, so nothing later can clobber the generated
+ # requirements.txt.
+ - name: Check out source repository
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ uses: actions/checkout@v7.0.1
+ with:
+ ref: ${{ github.head_ref }}
+ path: project-repo
+
+ - name: Install uv
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ uses: astral-sh/setup-uv@v10.0.1
+ with:
+ version: "latest"
+
+ # `--group ingestion` adds dagster and dlt on top of the runtime
+ # dependencies; the runtime ones are needed too, because the loader
+ # imports `db/` and `domain/`.
+ #
+ # `--no-hashes` is required on this path and only on this path. The PEX
+ # builder unions requirements.txt with `[project].dependencies` from
+ # pyproject.toml, and those pins carry no hashes -- a hashed
+ # requirements.txt would put pip in --require-hashes mode, where every
+ # unhashed line is an error. Both sources resolve from the same uv.lock,
+ # so the duplicate pins agree and drop out.
+ - name: Generate requirements.txt
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ working-directory: project-repo
+ run: |
+ uv export \
+ --format requirements-txt \
+ --no-emit-project \
+ --no-dev \
+ --no-hashes \
+ --group ingestion \
+ --output-file requirements.txt
+
+ # Builds deps.pex and source.pex and publishes them, then creates or
+ # updates the branch deployment for this PR -- the action derives the
+ # deployment name from the pull_request event, so there is no `deployment`
+ # input to set here.
+ #
+ # On an ubuntu-24.04 runner deps.pex is built inside a python:3.13-slim
+ # container so the wheels match the serverless base image; source.pex is
+ # always built on the runner. That container only spins up when the
+ # dependency hash changes.
+ - name: Deploy to Dagster+ branch deployment
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ uses: dagster-io/dagster-cloud-action/actions/build_deploy_python_executable@v1.13.18
+ with:
+ dagster_cloud_file: "$GITHUB_WORKSPACE/project-repo/${{ env.DAGSTER_CLOUD_FILE }}"
+ build_output_dir: "$GITHUB_WORKSPACE/build"
+ python_version: ${{ env.PYTHON_VERSION }}
+
+ # Fallback path, reached only when ENABLE_FAST_DEPLOYS is 'false' above. This
+ # is the pre-PEX workflow unchanged, including the post-install hook in
+ # dagster_cloud_post_install.sh that the PEX path replaces with its own
+ # `uv pip install --no-deps .` of the repository.
+ dagster-branch-docker-deploy:
+ runs-on: ubuntu-latest
+ needs: dagster-branch-deploy
+ if: needs.dagster-branch-deploy.outputs.build_info
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ strategy:
+ fail-fast: false
+ matrix:
+ location: ${{ fromJSON(needs.dagster-branch-deploy.outputs.build_info) }}
+
+ steps:
+ - name: Check out source repository
+ uses: actions/checkout@v7.0.1
+ with:
+ ref: ${{ github.head_ref }}
- name: Install uv in container
uses: astral-sh/setup-uv@v10.0.1
with:
version: "latest"
+ # Dagster+ builds from a requirements.txt. Hashes are kept here: this path
+ # feeds the file straight to `pip install -r`, with nothing unhashed mixed
+ # in.
- name: Generate requirements.txt
run: |
uv export \
@@ -74,17 +198,17 @@ jobs:
--group ingestion \
--output-file requirements.txt
- # Runs on `closed` too: the action tears the branch deployment down when
- # the PR is merged or abandoned, so stale deployments do not accumulate.
+ # checkout_repo is false because requirements.txt is generated above and
+ # a second checkout would discard it.
- name: Deploy to Dagster+ branch deployment
uses: dagster-io/dagster-cloud-action/actions/serverless_branch_deploy@v1.13.18
with:
organization_id: ${{ vars.DAGSTER_CLOUD_ORGANIZATION_ID }}
dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
- location: ${{ toJson(fromJson(steps.parse.outputs.build_info)[0]) }}
+ location: ${{ toJson(matrix.location) }}
checkout_repo: false
# The action defaults to python:3.8-slim, which cannot install a
# lockfile resolved for requires-python >= 3.13 -- pip reports the
# pins as having no matching distribution rather than as a version
# conflict, which reads like a broken requirements file.
- base_image: python:3.13-slim
+ base_image: python:${{ env.PYTHON_VERSION }}-slim
diff --git a/.github/workflows/CD_dagster_prod.yml b/.github/workflows/CD_dagster_prod.yml
index 5c55ffce3..aa5bfe34d 100644
--- a/.github/workflows/CD_dagster_prod.yml
+++ b/.github/workflows/CD_dagster_prod.yml
@@ -16,6 +16,20 @@
# filter includes pyproject.toml and uv.lock because the location's dependency
# set is exported from them, so a lockfile bump changes the built image even
# when no ingestion source file does.
+#
+# ## Two deploy paths
+#
+# The default path builds no container image at all: it packages the
+# dependencies and the source into two PEX files and uploads them, so a source
+# change reuses the previously published deps.pex instead of rebuilding and
+# pushing several hundred megabytes to ECR. See
+# https://dagster.io/blog/fast-deploys-with-pex-and-docker.
+#
+# Setting ENABLE_FAST_DEPLOYS to 'false' below switches the whole workflow back
+# to the Docker image build in the `dagster-prod-docker-deploy` job. That job is
+# the escape hatch for anything the PEX path cannot express -- a dependency with
+# no Linux wheel that also fails to build from source, or a system package that
+# has to be installed with apt. Keep both paths working.
name: CD (Dagster+ prod)
on:
@@ -44,6 +58,22 @@ concurrency:
group: dagster-prod-deploy
cancel-in-progress: false
+env:
+ # The PEX path targets `$DAGSTER_CLOUD_URL/`, so this is the
+ # organization URL with no deployment path appended. The Docker path takes the
+ # organization id as an action input instead and ignores this.
+ DAGSTER_CLOUD_URL: https://${{ vars.DAGSTER_CLOUD_ORGANIZATION_ID }}.dagster.cloud
+ DAGSTER_CLOUD_API_TOKEN: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+ # Read by actions/utils/prerun, which reports `pex-deploy` or `docker-deploy`
+ # and so decides which of the two jobs below runs.
+ ENABLE_FAST_DEPLOYS: "true"
+ # The PEX files are resolved for this interpreter only, and requires-python is
+ # >= 3.13. Anything lower makes the deps resolve fail with "no matching
+ # distribution", which reads like a broken requirements file rather than a
+ # version mismatch.
+ PYTHON_VERSION: "3.13"
+ DAGSTER_CLOUD_FILE: dagster_cloud.yaml
+
jobs:
dagster-prod-deploy:
runs-on: ubuntu-latest
@@ -54,29 +84,113 @@ jobs:
# it would put an approval gate on routine merges once `production` requires
# reviewers, which is a gate on the wrong thing: this publishes code, not
# data.
+ outputs:
+ # Set only on the Docker path. The fallback job keys off it being empty.
+ build_info: ${{ steps.parse.outputs.build_info }}
steps:
- - name: Check out source repository
- uses: actions/checkout@v7.0.1
+ # Reads ENABLE_FAST_DEPLOYS and emits `pex-deploy` or `docker-deploy`.
+ # Its own checkout goes to prerun_checkout_dir, so it does not disturb the
+ # working tree either path sets up below.
+ - name: Prerun checks
+ id: prerun
+ uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v1.13.18
# parse_workspace performs its own `actions/checkout`, which cleans the
- # working tree. It has to run *before* requirements.txt is generated, or
- # the generated file is deleted before the deploy step can use it.
+ # working tree -- fine here because the Docker path runs in a separate job
+ # that checks out again. Only its `build_info` output crosses the boundary.
- name: Parse dagster_cloud.yaml
+ if: steps.prerun.outputs.result == 'docker-deploy'
id: parse
uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v1.13.18
with:
- dagster_cloud_file: dagster_cloud.yaml
+ dagster_cloud_file: ${{ env.DAGSTER_CLOUD_FILE }}
+
+ # Checked out under a subdirectory because build_deploy_python_executable
+ # takes an absolute path to the location file and does not check out
+ # anything itself, so nothing later can clobber the generated
+ # requirements.txt.
+ - name: Check out source repository
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ uses: actions/checkout@v7.0.1
+ with:
+ ref: ${{ github.sha }}
+ path: project-repo
+
+ - name: Install uv
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ uses: astral-sh/setup-uv@v10.0.1
+ with:
+ version: "latest"
+
+ # `--group ingestion` adds dagster and dlt on top of the runtime
+ # dependencies; the runtime ones are needed too, because the loader
+ # imports `db/` and `domain/`.
+ #
+ # `--no-hashes` is required on this path and only on this path. The PEX
+ # builder unions requirements.txt with `[project].dependencies` from
+ # pyproject.toml, and those pins carry no hashes -- a hashed
+ # requirements.txt would put pip in --require-hashes mode, where every
+ # unhashed line is an error. Both sources resolve from the same uv.lock,
+ # so the duplicate pins agree and drop out.
+ - name: Generate requirements.txt
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ working-directory: project-repo
+ run: |
+ uv export \
+ --format requirements-txt \
+ --no-emit-project \
+ --no-dev \
+ --no-hashes \
+ --group ingestion \
+ --output-file requirements.txt
+
+ # Builds deps.pex and source.pex and publishes them. deps.pex is keyed by
+ # a hash of the resolved requirements and cached per repository, so a run
+ # that changes only ingestion source skips the dependency build entirely.
+ #
+ # On an ubuntu-24.04 runner the action builds deps.pex inside a
+ # python:3.13-slim container so the wheels match the serverless base
+ # image; source.pex is always built on the runner. That container only
+ # spins up when the dependency hash changes.
+ - name: Deploy to Dagster+ prod
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ uses: dagster-io/dagster-cloud-action/actions/build_deploy_python_executable@v1.13.18
+ with:
+ dagster_cloud_file: "$GITHUB_WORKSPACE/project-repo/${{ env.DAGSTER_CLOUD_FILE }}"
+ build_output_dir: "$GITHUB_WORKSPACE/build"
+ python_version: ${{ env.PYTHON_VERSION }}
+ deployment: prod
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # Fallback path, reached only when ENABLE_FAST_DEPLOYS is 'false' above. This
+ # is the pre-PEX workflow unchanged, including the post-install hook in
+ # dagster_cloud_post_install.sh that the PEX path replaces with its own
+ # `uv pip install --no-deps .` of the repository.
+ dagster-prod-docker-deploy:
+ runs-on: ubuntu-latest
+ needs: dagster-prod-deploy
+ if: needs.dagster-prod-deploy.outputs.build_info
+ strategy:
+ fail-fast: false
+ matrix:
+ location: ${{ fromJSON(needs.dagster-prod-deploy.outputs.build_info) }}
+
+ steps:
+ - name: Check out source repository
+ uses: actions/checkout@v7.0.1
+ with:
+ ref: ${{ github.sha }}
- name: Install uv in container
uses: astral-sh/setup-uv@v10.0.1
with:
version: "latest"
- # Dagster+ builds from a requirements.txt, which the repo does not keep
- # under version control. `--group ingestion` adds dagster and dlt on top
- # of the runtime dependencies; the runtime ones are needed too, because
- # the loader imports `db/` and `domain/`.
+ # Dagster+ builds from a requirements.txt. Hashes are kept here: this path
+ # feeds the file straight to `pip install -r`, with nothing unhashed mixed
+ # in.
- name: Generate requirements.txt
run: |
uv export \
@@ -93,10 +207,12 @@ jobs:
with:
organization_id: ${{ vars.DAGSTER_CLOUD_ORGANIZATION_ID }}
dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
- location: ${{ toJson(fromJson(steps.parse.outputs.build_info)[0]) }}
+ location: ${{ toJson(matrix.location) }}
checkout_repo: false
# The action defaults to python:3.8-slim, which cannot install a
# lockfile resolved for requires-python >= 3.13 -- pip reports the
# pins as having no matching distribution rather than as a version
# conflict, which reads like a broken requirements file.
- base_image: python:3.13-slim
+ base_image: python:${{ env.PYTHON_VERSION }}-slim
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/dagster_cloud_post_install.sh b/dagster_cloud_post_install.sh
index c093ae1c8..09cbc6e9b 100755
--- a/dagster_cloud_post_install.sh
+++ b/dagster_cloud_post_install.sh
@@ -1,7 +1,13 @@
#!/usr/bin/env bash
-# Runs inside the Dagster+ image build, after the repository has been copied to
+# Runs inside the Dagster+ *image* build, after the repository has been copied to
# /opt/dagster/app and the pinned requirements installed.
#
+# Only the Docker fallback path reaches this script. The default PEX path builds
+# no image: `dagster_cloud_cli`'s source-pex builder runs its own
+# `uv pip install --target ... --no-deps .` over this repository, which is the
+# same install by a different route. Both CD_dagster_*.yml workflows document
+# the switch between the two.
+#
# Installs this repository as a package so `db`, `domain`, `services`, `core`,
# and `schemas` resolve from site-packages. Without it they are importable only
# while /opt/dagster/app happens to be on sys.path -- true for the process that
From b7a6fb3e80092f05f212564c71049b4f5507fec3 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 14:14:39 -0700
Subject: [PATCH 114/151] ci(dagster): add a dispatchable heartbeat smoke test
A successful deploy is weaker evidence than a successful run. The agent
loading the code location proves the loader process can import the
package; it says nothing about the process that executes a step, which is
a different process with a different sys.path. That gap is why
assets/heartbeat.py exists, and it matters more now that the code location
ships as PEX files rather than an image, because the two package the
repository by different routes.
Nothing could launch that asset from CI. dagster-cloud-action's launch_job
identifies what to run by job name and exposes no asset selection, so an
asset reachable only through the implicit __ASSET_JOB is unreachable. So
wrap it in a named job, ingestion_heartbeat_check, and add a
workflow_dispatch workflow that launches it against a deployment given as
an input -- a branch deployment id or prod -- with wait: true so the
workflow result is the materialization result rather than just "a run was
launched".
Dispatch-only on purpose. It costs a Dagster+ run, and the interesting
time to spend one is after a deploy that changed how the code location is
packaged, not on every push.
No retry policy on the job: a retry would mask exactly the failure it
exists to surface, since an import that works at load time and fails at
execution does so deterministically.
Co-Authored-By: Claude Opus 5
---
.github/workflows/smoke_dagster_location.yml | 51 ++++++++++++++++++++
automated_ingestion/defs/definitions.py | 3 +-
automated_ingestion/defs/jobs/heartbeat.py | 49 +++++++++++++++++++
3 files changed, 102 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/smoke_dagster_location.yml
create mode 100644 automated_ingestion/defs/jobs/heartbeat.py
diff --git a/.github/workflows/smoke_dagster_location.yml b/.github/workflows/smoke_dagster_location.yml
new file mode 100644
index 000000000..c3d40e37e
--- /dev/null
+++ b/.github/workflows/smoke_dagster_location.yml
@@ -0,0 +1,51 @@
+# Materializes the heartbeat asset on a Dagster+ deployment, on demand.
+#
+# This is the run-time half of the deploy check. CD_dagster_*.yml prove the
+# agent can load the code location; the loader is not the process that executes
+# a step, and the two do not necessarily agree about sys.path, so loading
+# cleanly does not prove a step can import `db` or `domain`. Materializing
+# `ingestion_heartbeat` does. It touches no database, no network and no GCS, so a
+# failure here is a packaging or deployment problem and nothing else.
+#
+# Dispatch-only. It costs a Dagster+ run, and the interesting time to spend one
+# is after a deploy that changed how the code location is packaged -- not on
+# every push.
+#
+# `deployment` takes a branch deployment id (the hex name in the Dagster+ URL,
+# which CD_dagster_branch.yml prints as "Deploying to branch deployment: ...")
+# or `prod`. Run it against the ref whose deployment you are testing: the job
+# must exist in the deployed code location, not just on the branch.
+name: Smoke test (Dagster+ code location)
+
+on:
+ workflow_dispatch:
+ inputs:
+ deployment:
+ description: "Dagster+ deployment: a branch deployment id, or 'prod'"
+ required: true
+ default: "prod"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: dagster-smoke-${{ github.event.inputs.deployment }}
+ cancel-in-progress: false
+
+jobs:
+ heartbeat:
+ runs-on: ubuntu-latest
+ steps:
+ # wait: true makes the action poll the run and fail the step if the run
+ # fails, so the workflow result is the materialization result rather than
+ # just "a run was launched".
+ - name: Materialize ingestion_heartbeat
+ uses: dagster-io/dagster-cloud-action/actions/launch_job@v1.13.18
+ with:
+ organization_id: ${{ vars.DAGSTER_CLOUD_ORGANIZATION_ID }}
+ dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+ location_name: ocotillo-automated-ingestion
+ deployment: ${{ github.event.inputs.deployment }}
+ job_name: ingestion_heartbeat_check
+ wait: "true"
+ interval: "10"
diff --git a/automated_ingestion/defs/definitions.py b/automated_ingestion/defs/definitions.py
index 3d4bd6c72..1bf2f7947 100644
--- a/automated_ingestion/defs/definitions.py
+++ b/automated_ingestion/defs/definitions.py
@@ -24,6 +24,7 @@
from dagster import Definitions
from automated_ingestion.defs.assets import all_assets
+from automated_ingestion.defs.jobs.heartbeat import heartbeat_job
from automated_ingestion.defs.jobs.san_acacia import (
san_acacia_job,
san_acacia_weekly_schedule,
@@ -32,7 +33,7 @@
defs = Definitions(
assets=all_assets(),
- jobs=[san_acacia_job],
+ jobs=[heartbeat_job, san_acacia_job],
schedules=[san_acacia_weekly_schedule],
resources={"database": OcotilloDatabase()},
)
diff --git a/automated_ingestion/defs/jobs/heartbeat.py b/automated_ingestion/defs/jobs/heartbeat.py
new file mode 100644
index 000000000..d95f80b66
--- /dev/null
+++ b/automated_ingestion/defs/jobs/heartbeat.py
@@ -0,0 +1,49 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+A named job wrapping the heartbeat asset, so a deploy can be smoke-tested
+without the Dagster+ UI.
+
+The asset alone is not enough for that: `dagster-cloud-action`'s `launch_job`
+identifies what to run by job name and exposes no asset selection, so an asset
+reachable only through the implicit `__ASSET_JOB` cannot be launched from CI.
+`.github/workflows/smoke_dagster_location.yml` runs this one.
+
+Worth having because a successful deploy is weaker evidence than a successful
+run. The agent loading the code location proves the *loader* process can import
+the package; it says nothing about the process that executes a step, which is a
+different process with a different sys.path. See the note in
+`assets/heartbeat.py` -- that gap is the whole reason the asset exists, and this
+job is how CI closes it.
+"""
+
+from dagster import AssetSelection, define_asset_job
+
+from automated_ingestion.defs.assets.heartbeat import ingestion_heartbeat
+
+heartbeat_job = define_asset_job(
+ name="ingestion_heartbeat_check",
+ selection=AssetSelection.assets(ingestion_heartbeat),
+ description=(
+ "Materialize the heartbeat asset only. Touches no database, no network, "
+ "and no GCS, so a failure is a packaging or deployment problem."
+ ),
+ # No retry policy. A retry would mask exactly the failure this job exists to
+ # surface: an import that works at load time and fails at execution does so
+ # deterministically.
+)
+
+# ============= EOF =============================================
From 77195a89d775ffafedcf209cbb415cace10e28c6 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 14:18:30 -0700
Subject: [PATCH 115/151] ci(dagster): materialize the heartbeat after a branch
deploy
The dispatch-only smoke test cannot cover the case it was written for.
GitHub offers workflow_dispatch only for workflows present on the default
branch, so a PR that changes how the code location is packaged -- exactly
when the check is worth running -- cannot run it.
So run it from the deploy workflow, as the last step of the PEX path. That
also fixes the ordering for free: a separate workflow would race the
deploy and could launch the job before the agent has synced the new code
location, or before the job exists in it at all.
The deployment name has to be resolved rather than assumed. It is derived
from the branch and build_deploy_python_executable does not report it, so
ask for it with the same `ci branch-deployment` call the deploy makes
internally; that call is idempotent and returns the existing deployment
for the PR.
smoke_dagster_location.yml stays, with its purpose narrowed to the cases
the deploy workflow does not reach: prod, and branch deployments that
predate this step.
Co-Authored-By: Claude Opus 5
---
.github/workflows/CD_dagster_branch.yml | 37 ++++++++++++++++++++
.github/workflows/smoke_dagster_location.yml | 11 ++++--
2 files changed, 45 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/CD_dagster_branch.yml b/.github/workflows/CD_dagster_branch.yml
index 7c0928788..6d7a61d28 100644
--- a/.github/workflows/CD_dagster_branch.yml
+++ b/.github/workflows/CD_dagster_branch.yml
@@ -160,6 +160,43 @@ jobs:
build_output_dir: "$GITHUB_WORKSPACE/build"
python_version: ${{ env.PYTHON_VERSION }}
+ # Materializing the heartbeat is the run-time half of the check. The steps
+ # above prove the agent can load the code location; the loader is not the
+ # process that executes a step, and the two do not necessarily agree about
+ # sys.path, so loading cleanly does not prove a step can import `db` or
+ # `domain`. This does. The asset touches no database, no network and no
+ # GCS, so a failure here is a packaging problem and nothing else -- which
+ # is worth one Dagster+ run on a PR that already changed how the code
+ # location is packaged.
+ #
+ # The deployment name has to be asked for rather than assumed: it is
+ # derived from the branch, and the deploy action does not report it. This
+ # is the same `ci branch-deployment` call the deploy makes internally, and
+ # it is idempotent -- it returns the existing deployment for this PR.
+ - name: Resolve branch deployment name
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ id: branch_deployment
+ run: |
+ name=$(uvx --from "dagster-cloud-cli==1.13.18" \
+ dagster-cloud ci branch-deployment project-repo)
+ echo "name=$name" >> "$GITHUB_OUTPUT"
+ echo "Branch deployment: $name"
+
+ # wait: true makes the action poll the run and fail this step if the run
+ # fails, so a green check means the asset materialized rather than just
+ # that a run was launched.
+ - name: Materialize ingestion_heartbeat
+ if: steps.prerun.outputs.result == 'pex-deploy'
+ uses: dagster-io/dagster-cloud-action/actions/launch_job@v1.13.18
+ with:
+ organization_id: ${{ vars.DAGSTER_CLOUD_ORGANIZATION_ID }}
+ dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+ location_name: ocotillo-automated-ingestion
+ deployment: ${{ steps.branch_deployment.outputs.name }}
+ job_name: ingestion_heartbeat_check
+ wait: "true"
+ interval: "10"
+
# Fallback path, reached only when ENABLE_FAST_DEPLOYS is 'false' above. This
# is the pre-PEX workflow unchanged, including the post-install hook in
# dagster_cloud_post_install.sh that the PEX path replaces with its own
diff --git a/.github/workflows/smoke_dagster_location.yml b/.github/workflows/smoke_dagster_location.yml
index c3d40e37e..f5d59a533 100644
--- a/.github/workflows/smoke_dagster_location.yml
+++ b/.github/workflows/smoke_dagster_location.yml
@@ -7,9 +7,14 @@
# `ingestion_heartbeat` does. It touches no database, no network and no GCS, so a
# failure here is a packaging or deployment problem and nothing else.
#
-# Dispatch-only. It costs a Dagster+ run, and the interesting time to spend one
-# is after a deploy that changed how the code location is packaged -- not on
-# every push.
+# Dispatch-only, and mainly for `prod`: CD_dagster_branch.yml already runs this
+# same job against a branch deployment as the last step of every PEX deploy, so
+# on a PR the check happens without anyone asking. This workflow is the way to
+# ask for it anywhere else -- after a prod deploy, or against a branch
+# deployment that was deployed before this check existed.
+#
+# Note a GitHub constraint: `workflow_dispatch` is only offered for workflows
+# present on the default branch, so this is not runnable from a feature branch.
#
# `deployment` takes a branch deployment id (the hex name in the Dagster+ URL,
# which CD_dagster_branch.yml prints as "Deploying to branch deployment: ...")
From f95ad29ddee91318b7076f4271fc1e1f66fd9a29 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 14:24:45 -0700
Subject: [PATCH 116/151] fix(ci): assert the heartbeat run succeeded
The previous commit claimed a green check meant the asset materialized.
It did not. `launch_job`'s run.sh captures the CLI output in a command
substitution and never checks the exit code, deciding success by whether
it can regex a run id out of the text. Underneath, `dagster-cloud job
launch --wait` reports a failed run with `ui.error(...)` -- and `ui.error`
only returns an exception rather than raising it, so the CLI exits 0 as
well. The action documents "fail if the run fails"; at neither layer can
it. A failed materialization produced a passing step.
Call the CLI directly and require the "finished successfully" line.
Matching on output is not lovely, but it is the only signal either layer
emits, and an assertion that can fail is worth more than one that reads
better.
Co-Authored-By: Claude Opus 5
---
.github/workflows/CD_dagster_branch.yml | 43 ++++++++++++++------
.github/workflows/smoke_dagster_location.yml | 42 +++++++++++++------
2 files changed, 61 insertions(+), 24 deletions(-)
diff --git a/.github/workflows/CD_dagster_branch.yml b/.github/workflows/CD_dagster_branch.yml
index 6d7a61d28..5b349474e 100644
--- a/.github/workflows/CD_dagster_branch.yml
+++ b/.github/workflows/CD_dagster_branch.yml
@@ -182,20 +182,39 @@ jobs:
echo "name=$name" >> "$GITHUB_OUTPUT"
echo "Branch deployment: $name"
- # wait: true makes the action poll the run and fail this step if the run
- # fails, so a green check means the asset materialized rather than just
- # that a run was launched.
+ # Deliberately not the vendor's `launch_job` action, and the success
+ # assertion is deliberately our own.
+ #
+ # `launch_job` documents `wait: true` as "the action will wait for the run
+ # to finish and fail if the run fails". It does wait, but it cannot fail:
+ # its run.sh captures the CLI output in a command substitution and never
+ # checks the exit code, deciding success by whether it can regex a run id
+ # out of the text. Underneath, `dagster-cloud job launch --wait` reports a
+ # failed run with `ui.error(...)` -- and `ui.error` only *returns* an
+ # exception rather than raising it, so the CLI exits 0 too. A failed
+ # materialization would have produced a green check on both counts.
+ #
+ # So call the CLI directly and require the success line. Matching on output
+ # is not lovely, but it is the only signal either layer actually emits.
- name: Materialize ingestion_heartbeat
if: steps.prerun.outputs.result == 'pex-deploy'
- uses: dagster-io/dagster-cloud-action/actions/launch_job@v1.13.18
- with:
- organization_id: ${{ vars.DAGSTER_CLOUD_ORGANIZATION_ID }}
- dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
- location_name: ocotillo-automated-ingestion
- deployment: ${{ steps.branch_deployment.outputs.name }}
- job_name: ingestion_heartbeat_check
- wait: "true"
- interval: "10"
+ env:
+ DAGSTER_CLOUD_API_TOKEN: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+ DEPLOYMENT: ${{ steps.branch_deployment.outputs.name }}
+ run: |
+ set -uo pipefail
+ out=$(uvx --from "dagster-cloud-cli==1.13.18" \
+ dagster-cloud job launch \
+ --url "$DAGSTER_CLOUD_URL" \
+ --deployment "$DEPLOYMENT" \
+ --location ocotillo-automated-ingestion \
+ --job ingestion_heartbeat_check \
+ --wait --interval 10 2>&1 | tee /dev/stderr)
+ case "$out" in
+ *"finished successfully"*) ;;
+ *) echo "::error title=Heartbeat failed::ingestion_heartbeat did not finish successfully on $DEPLOYMENT"
+ exit 1 ;;
+ esac
# Fallback path, reached only when ENABLE_FAST_DEPLOYS is 'false' above. This
# is the pre-PEX workflow unchanged, including the post-install hook in
diff --git a/.github/workflows/smoke_dagster_location.yml b/.github/workflows/smoke_dagster_location.yml
index f5d59a533..d38ab9ad8 100644
--- a/.github/workflows/smoke_dagster_location.yml
+++ b/.github/workflows/smoke_dagster_location.yml
@@ -37,20 +37,38 @@ concurrency:
group: dagster-smoke-${{ github.event.inputs.deployment }}
cancel-in-progress: false
+env:
+ DAGSTER_CLOUD_URL: https://${{ vars.DAGSTER_CLOUD_ORGANIZATION_ID }}.dagster.cloud
+
jobs:
heartbeat:
runs-on: ubuntu-latest
steps:
- # wait: true makes the action poll the run and fail the step if the run
- # fails, so the workflow result is the materialization result rather than
- # just "a run was launched".
- - name: Materialize ingestion_heartbeat
- uses: dagster-io/dagster-cloud-action/actions/launch_job@v1.13.18
+ - name: Install uv
+ uses: astral-sh/setup-uv@v10.0.1
with:
- organization_id: ${{ vars.DAGSTER_CLOUD_ORGANIZATION_ID }}
- dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
- location_name: ocotillo-automated-ingestion
- deployment: ${{ github.event.inputs.deployment }}
- job_name: ingestion_heartbeat_check
- wait: "true"
- interval: "10"
+ version: "latest"
+
+ # The vendor's `launch_job` action is not used here, for the reason spelled
+ # out in CD_dagster_branch.yml: neither it nor `dagster-cloud job launch`
+ # exits nonzero on a failed run, so `wait: true` waits without gating.
+ # Requiring the success line is what makes this workflow's result mean
+ # something.
+ - name: Materialize ingestion_heartbeat
+ env:
+ DAGSTER_CLOUD_API_TOKEN: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+ DEPLOYMENT: ${{ github.event.inputs.deployment }}
+ run: |
+ set -uo pipefail
+ out=$(uvx --from "dagster-cloud-cli==1.13.18" \
+ dagster-cloud job launch \
+ --url "$DAGSTER_CLOUD_URL" \
+ --deployment "$DEPLOYMENT" \
+ --location ocotillo-automated-ingestion \
+ --job ingestion_heartbeat_check \
+ --wait --interval 10 2>&1 | tee /dev/stderr)
+ case "$out" in
+ *"finished successfully"*) ;;
+ *) echo "::error title=Heartbeat failed::ingestion_heartbeat did not finish successfully on $DEPLOYMENT"
+ exit 1 ;;
+ esac
From 63bf5023e1cd4e538671350b698acbd4107d5562 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 15:42:01 -0700
Subject: [PATCH 117/151] feat(transducer): publish and range-delete for
corrected hydrographs
The hydrograph corrector in OcotilloUI could only download its corrected
series as CSV -- there was no POST for transducer observations at all. This
adds the two write endpoints its upload contract specifies.
POST /observation/transducer-groundwater-level/block publishes one corrected
logger file as one block plus all of its readings, in one transaction. The
block's span is derived from the measurements rather than sent: nothing links
the observation table to the block table, so the reader pairs them by time and
a client-supplied span wider than the data would make the block claim readings
it does not contain. `deployment_id` is optional and resolved from the
deployments covering that span; zero or more than one match is a 422 rather
than a guess, because guessing attributes readings to hardware that did not
record them.
An existing block sharing any instant with the new one is a 409 listing the
collisions. `?replace_overlapping=true` deletes those blocks and their
readings. The readings have to go with the block -- keeping them would leave
rows the reader cannot show that still occupy the deployment/parameter/instant
the new series is about to claim, so a "replace" that kept them would fail on
the very insert it was asked to make room for. Readings orphaned by a
hand-deleted block are caught separately and reported with the earliest
colliding timestamp, rather than letting the insert abort on a constraint name.
Overlap is inclusive on both bounds, unlike TransducerObservationBlock.overlaps,
which is half-open. The reader matches with `start <= t <= end`, so two blocks
sharing an endpoint both claim a reading at that instant -- exactly the
ambiguity the check exists to prevent.
DELETE /observation/transducer-groundwater-level removes every reading for a
well inside a closed range and reconciles the blocks that covered them: one
left empty is deleted, one left partial has its span narrowed to the survivors.
All three parameters are required; there is deliberately no unbounded form.
Scope matches the GET on the same path, so the set a client previews is the set
this removes.
Schema changes: provenance on the block (source_file, source_kind, and an
ordered corrections list), and a per-reading `note` set only where a correction
moved the value, so NULL reads as "as measured" rather than "unknown". A
corrected block is derived data, and a reviewer who cannot see that a series
was snapped to a manual measurement cannot review it. The block time-order
check is relaxed to `end >= start`: a block covering a single instant is
legitimate, either published that way or narrowed to it by a delete.
Both write routes are gated on AMP.Staging, a standalone group -- AMPAdmin does
not satisfy it and it satisfies nothing else -- so they ship dark while the
workbench is validated against real logger files.
Two bugs fixed in passing:
- The read route called get_transducer_observations positionally, and the
helper's fourth positional parameter is `sensor_id`. `start_time` landed in
`sensor_id` (unused, dropped), `end_time` landed in `start_time`, and
`end_time` was never set, so a requested upper bound was ignored and the
lower bound came from the wrong argument.
- `sort`/`order` on that route were accepted and ignored. They now work over a
whitelist; an unrecognised field is a 422 rather than a silently differently
ordered page.
Wellntel support from the contract is deferred: both the readings proxy and the
sensor_type filter on /thing are blocked on where the API key and the
wellname/PointID mapping should live.
Co-Authored-By: Claude Opus 5
---
CLAUDE.md | 6 +
...4e5f6a7b8_hydrograph_correction_publish.py | 116 ++++
api/observation.py | 118 +++-
core/dependencies.py | 17 +
db/transducer.py | 45 +-
docs/hydrograph-correction-publish.md | 110 ++++
domain/hydrograph.py | 163 +++++
schemas/transducer.py | 130 +++-
services/observation_helper.py | 49 +-
services/transducer_helper.py | 459 +++++++++++++
tests/test_authorization.py | 1 +
tests/test_domain_hydrograph.py | 179 +++++
tests/test_transducer_publish.py | 610 ++++++++++++++++++
13 files changed, 1988 insertions(+), 15 deletions(-)
create mode 100644 alembic/versions/c3d4e5f6a7b8_hydrograph_correction_publish.py
create mode 100644 docs/hydrograph-correction-publish.md
create mode 100644 domain/hydrograph.py
create mode 100644 services/transducer_helper.py
create mode 100644 tests/test_domain_hydrograph.py
create mode 100644 tests/test_transducer_publish.py
diff --git a/CLAUDE.md b/CLAUDE.md
index 30549235f..1bb5bdcaa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -164,6 +164,12 @@ Authentik groups granted.
**Role families are orthogonal**: general `Admin` confers nothing in the AMP or
Lexicon families. Only tiers *within* a family nest.
+**`AMP.Staging`** is a standalone group, not a fourth AMP tier — `AMPAdmin`
+does not satisfy it. It gates the hydrograph corrector's publish and range-delete
+routes while the workbench is being validated against real logger files, so they
+ship dark. Read **`docs/hydrograph-correction-publish.md`** before changing
+them.
+
**Authorization is opt-in per endpoint** — a `user: _dependency` parameter
in the signature, not a router-level `dependencies=[...]`. Omitting it produces a
fully public endpoint with no error. `tests/test_authorization.py` holds the
diff --git a/alembic/versions/c3d4e5f6a7b8_hydrograph_correction_publish.py b/alembic/versions/c3d4e5f6a7b8_hydrograph_correction_publish.py
new file mode 100644
index 000000000..d3f20e363
--- /dev/null
+++ b/alembic/versions/c3d4e5f6a7b8_hydrograph_correction_publish.py
@@ -0,0 +1,116 @@
+"""publish provenance for corrected transducer blocks
+
+Revision ID: c3d4e5f6a7b8
+Revises: b2c3d4e5f6a7
+Create Date: 2026-08-19
+
+The hydrograph corrector publishes a *derived* series: water head converted to
+depth below ground surface against manual anchors, then shifted, snapped, and
+drift-corrected. None of those numbers are what the instrument recorded, so the
+database has to carry enough to tell a reviewer what happened to them.
+
+Three columns on the block cover the batch: the file it came from, whether that
+file held water head or depth to water, and the ordered list of corrections
+applied. `comment` already exists and takes the publisher's free-text note.
+
+One column on the observation covers the row: `note`, set only on readings a
+correction actually moved. NULL therefore means "as measured", which is the
+distinction review needs. The legacy `nma_waterlevelscontinuous_*_notes`
+columns cannot serve -- each is scoped to one legacy source table.
+
+The block time-order check is relaxed from `>` to `>=`. A block spanning a
+single instant is legitimate: a published file with one reading, or a block
+narrowed by a range delete until one observation survives. The block reader
+matches observations inclusively on both bounds, so a zero-width block still
+covers its reading. Loosening a check constraint cannot invalidate existing
+rows.
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+revision = "c3d4e5f6a7b8"
+down_revision = "b2c3d4e5f6a7"
+branch_labels = None
+depends_on = None
+
+# Spelled as it exists in the database, typo included -- renaming it here would
+# leave deployed environments with a constraint this migration cannot find.
+TIME_ORDER_CONSTRAINT = "check_transuder_block_time_order"
+
+
+def upgrade() -> None:
+ op.add_column(
+ "transducer_observation_block",
+ sa.Column(
+ "source_file",
+ sa.String(length=255),
+ nullable=True,
+ comment="Name of the logger file the corrected series was derived from",
+ ),
+ )
+ op.add_column(
+ "transducer_observation_block",
+ sa.Column(
+ "source_kind",
+ sa.String(length=50),
+ nullable=True,
+ comment="What the source file measured: water_head or depth_to_water",
+ ),
+ )
+ op.add_column(
+ "transducer_observation_block",
+ sa.Column(
+ "corrections",
+ postgresql.JSONB(astext_type=sa.Text()),
+ nullable=True,
+ comment="Corrections applied to the source series, in applied order",
+ ),
+ )
+ op.add_column(
+ "transducer_observation",
+ sa.Column(
+ "note",
+ sa.Text(),
+ nullable=True,
+ comment=(
+ "Per-reading correction annotation; NULL means the value is as "
+ "measured"
+ ),
+ ),
+ )
+
+ op.drop_constraint(
+ TIME_ORDER_CONSTRAINT, "transducer_observation_block", type_="check"
+ )
+ op.create_check_constraint(
+ TIME_ORDER_CONSTRAINT,
+ "transducer_observation_block",
+ "end_datetime >= start_datetime",
+ )
+
+
+def downgrade() -> None:
+ # Zero-width blocks may have been created while the loosened constraint was
+ # in force, so widen them by a second rather than let the stricter
+ # constraint fail to validate. A one-second span on a block that covered an
+ # instant is a smaller lie than a failed downgrade.
+ op.execute(
+ "UPDATE transducer_observation_block "
+ "SET end_datetime = start_datetime + interval '1 second' "
+ "WHERE end_datetime = start_datetime"
+ )
+ op.drop_constraint(
+ TIME_ORDER_CONSTRAINT, "transducer_observation_block", type_="check"
+ )
+ op.create_check_constraint(
+ TIME_ORDER_CONSTRAINT,
+ "transducer_observation_block",
+ "end_datetime > start_datetime",
+ )
+
+ op.drop_column("transducer_observation", "note")
+ op.drop_column("transducer_observation_block", "corrections")
+ op.drop_column("transducer_observation_block", "source_kind")
+ op.drop_column("transducer_observation_block", "source_file")
diff --git a/api/observation.py b/api/observation.py
index d4c7fff78..4e084036c 100644
--- a/api/observation.py
+++ b/api/observation.py
@@ -28,6 +28,7 @@
session_dependency,
amp_admin_dependency,
amp_editor_dependency,
+ amp_staging_dependency,
amp_viewer_dependency,
)
from db import Observation, Parameter
@@ -40,7 +41,12 @@
UpdateGroundwaterLevelObservation,
UpdateWaterChemistryObservation,
)
-from schemas.transducer import TransducerObservationWithBlockResponse
+from schemas.transducer import (
+ DeletedTransducerObservationsResponse,
+ PublishedTransducerBlockResponse,
+ PublishTransducerBlock,
+ TransducerObservationWithBlockResponse,
+)
from schemas.water_level_csv import WaterLevelBulkUploadResponse
from services.crud_helper import model_deleter, model_adder
from services.observation_helper import (
@@ -50,10 +56,30 @@
get_transducer_observations,
)
from services.query_helper import simple_get_by_id
+from services.transducer_helper import (
+ delete_transducer_observations,
+ publish_transducer_block,
+)
from services.water_level_csv import bulk_upload_water_levels
router = APIRouter(prefix="/observation", tags=["observation"])
+
+def _groundwater_level_parameter_id(session) -> int:
+ """
+ The lexicon id the transducer routes work in.
+
+ Looked up rather than configured so the publish, read, and delete routes
+ cannot drift onto different parameters.
+ """
+ return (
+ session.query(Parameter)
+ .filter(Parameter.parameter_name == "groundwater level")
+ .one()
+ .id
+ )
+
+
"""
TODO
@@ -88,6 +114,33 @@ def add_water_chemistry_observation(
return model_adder(session, Observation, obs_data, user=user)
+@router.post(
+ "/transducer-groundwater-level/block",
+ status_code=HTTP_201_CREATED,
+ summary="Publish a corrected transducer series as one block",
+)
+def publish_transducer_groundwater_level_block(
+ payload: PublishTransducerBlock,
+ session: session_dependency,
+ user: amp_staging_dependency,
+ replace_overlapping: bool = False,
+) -> PublishedTransducerBlockResponse:
+ """
+ Publish one corrected logger file as a single observation block.
+
+ The block's time span is derived from the measurements; the client does not
+ send it. Overlapping an existing block is a 409 listing the collisions --
+ pass `replace_overlapping=true` to supersede them, which deletes those
+ blocks and their readings in the same transaction.
+
+ Written by the hydrograph corrector in OcotilloUI. See
+ `docs/hydrograph-correction-publish.md`.
+ """
+ return publish_transducer_block(
+ session, payload, user=user, replace_overlapping=replace_overlapping
+ )
+
+
@router.post(
"/groundwater-level/bulk-upload",
response_model=WaterLevelBulkUploadResponse,
@@ -155,17 +208,30 @@ def get_transducer_groundwater_level_observations(
thing_id: int | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
+ sort: str | None = None,
+ order: str | None = None,
) -> CustomPage[TransducerObservationWithBlockResponse]:
+ """
+ Retrieve transducer groundwater level observations paired with the block
+ that covers them.
- groundwater_parameter_id = (
- session.query(Parameter)
- .filter(Parameter.parameter_name == "groundwater level")
- .one()
- .id
- )
-
+ `sort` accepts `observation_datetime`, `value`, or `id`; `order` accepts
+ `asc` or `desc`. The default is newest first, so a client that wants the
+ latest stored reading for a well can ask for size 1.
+ """
+ # Keyword arguments deliberately: the helper's fourth positional parameter
+ # is `sensor_id`, so the previous positional call passed `start_time` as a
+ # sensor id (unused, silently dropped), `end_time` as `start_time`, and
+ # nothing as `end_time` -- an upper bound the caller asked for was ignored
+ # and the lower bound came from the wrong argument.
return get_transducer_observations(
- session, thing_id, groundwater_parameter_id, start_time, end_time
+ session,
+ thing_id=thing_id,
+ parameter_id=_groundwater_level_parameter_id(session),
+ start_time=start_time,
+ end_time=end_time,
+ sort=sort,
+ order=order,
)
@@ -302,6 +368,40 @@ def get_observation_by_id(
# DELETE =======================================================================
+@router.delete(
+ "/transducer-groundwater-level",
+ status_code=HTTP_200_OK,
+ summary="Delete transducer groundwater level observations in a time range",
+)
+def delete_transducer_groundwater_level_observations(
+ session: session_dependency,
+ user: amp_staging_dependency,
+ thing_id: int,
+ start_time: datetime,
+ end_time: datetime,
+) -> DeletedTransducerObservationsResponse:
+ """
+ Delete every transducer groundwater level reading for a well inside a
+ closed time range, and reconcile the blocks that covered them: a block left
+ with no readings is deleted, one left with some has its span narrowed to
+ the survivors.
+
+ All three parameters are required -- there is deliberately no form of this
+ request that deletes everything for a well. Scoped exactly like the `GET`
+ on this path, so the set previewed there is the set removed here.
+
+ Irreversible, and it leaves the `transducer_daily_data` materialized view
+ stale until its next refresh.
+ """
+ return delete_transducer_observations(
+ session,
+ thing_id=thing_id,
+ parameter_id=_groundwater_level_parameter_id(session),
+ start_time=start_time,
+ end_time=end_time,
+ )
+
+
@router.delete(
"/{observation_id}",
summary="Delete an observation",
diff --git a/core/dependencies.py b/core/dependencies.py
index 09e7c3f79..95d11f3c8 100644
--- a/core/dependencies.py
+++ b/core/dependencies.py
@@ -59,6 +59,21 @@
amp_viewer_function = authenticated(any_of=["AMPAdmin", "AMPEditor", "AMPViewer"])
+# Hydrograph-Corrector Staging Permissions -------------------------------------
+# The hydrograph corrector's publish and range-delete routes write and destroy
+# transducer records, and the workbench driving them is still being validated
+# against real logger files. `AMP.Staging` is its own group with no tier below
+# it and no AMP tier above it -- an AMPAdmin does not satisfy it. Nobody holds
+# it until it is granted in Authentik, so the routes ship dark and reachable
+# only by whoever is testing them.
+#
+# This is deliberately not a fourth rung on the AMP ladder. When the workbench
+# is trusted, these routes move to `amp_admin_dependency` and the group goes
+# away; leaving it as a tier would make that a schema change instead of a
+# one-line edit.
+amp_staging_function = authenticated(any_of=["AMP.Staging"])
+
+
# Lexicon-Specific Authentication/Permissions ----------------------------------
lexicon_admin_function = authenticated(any_of=["LexiconAdmin"])
@@ -89,5 +104,7 @@
amp_editor_dependency: TypeAlias = Annotated[dict, Depends(amp_editor_function)]
amp_viewer_dependency: TypeAlias = Annotated[dict, Depends(amp_viewer_function)]
+amp_staging_dependency: TypeAlias = Annotated[dict, Depends(amp_staging_function)]
+
no_permission_dependency: TypeAlias = Annotated[dict, Depends(no_permission_function)]
# ============= EOF =============================================
diff --git a/db/transducer.py b/db/transducer.py
index d109adc58..65766f6e5 100644
--- a/db/transducer.py
+++ b/db/transducer.py
@@ -28,6 +28,7 @@
Index,
UniqueConstraint,
)
+from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import mapped_column, Mapped, relationship
from db import Base, AutoBaseMixin, ReleaseMixin, lexicon_term
@@ -62,6 +63,32 @@ class TransducerObservationBlock(Base, AutoBaseMixin, ReleaseMixin):
)
comment: Mapped[str] = mapped_column(Text, nullable=True)
+
+ # Publish provenance. A corrected block is derived data -- the numbers in it
+ # are not what any instrument recorded -- so the file it came from and the
+ # operations applied to it are part of the record, not metadata about it. A
+ # reviewer who cannot see that a series was snapped to a manual measurement
+ # cannot review it.
+ source_file: Mapped[str] = mapped_column(
+ String(255),
+ nullable=True,
+ comment="Name of the logger file the corrected series was derived from",
+ )
+ source_kind: Mapped[str] = mapped_column(
+ String(50),
+ nullable=True,
+ comment="What the source file measured: water_head or depth_to_water",
+ )
+ # A list of strings in applied order rather than a modelled correction
+ # entity: the corrector's operation set is still moving, and freezing it
+ # into columns now would mean a migration per new operation. The strings
+ # are written by the workbench and read by humans.
+ corrections: Mapped[list] = mapped_column(
+ JSONB,
+ nullable=True,
+ comment="Corrections applied to the source series, in applied order",
+ )
+
reviewer_id: Mapped[str] = mapped_column(
ForeignKey("contact.id", ondelete="CASCADE"),
nullable=True,
@@ -81,8 +108,13 @@ class TransducerObservationBlock(Base, AutoBaseMixin, ReleaseMixin):
"end_datetime",
name="uq_transducer_block_thing_status_parameter_time",
),
+ # Non-strict: a block covering a single instant is legitimate -- a
+ # published file with one reading, or a block narrowed by a range
+ # delete until one observation survives. The block reader matches
+ # observations inclusively on both bounds, so a zero-width block still
+ # covers its reading.
CheckConstraint(
- "end_datetime > start_datetime", name="check_transuder_block_time_order"
+ "end_datetime >= start_datetime", name="check_transuder_block_time_order"
),
Index(
"ix_transducer_block_time",
@@ -137,6 +169,17 @@ class TransducerObservation(Base, AutoBaseMixin, ReleaseMixin):
)
value: Mapped[float] = mapped_column(Float, nullable=False)
+ # Why this reading differs from what the sensor recorded. Present only on
+ # readings a correction actually moved, so a NULL note means the value is
+ # as measured -- which is the distinction review needs and which the legacy
+ # `nma_waterlevelscontinuous_*_notes` columns cannot carry, being scoped to
+ # one legacy source each.
+ note: Mapped[str] = mapped_column(
+ Text,
+ nullable=True,
+ comment="Per-reading correction annotation; NULL means the value is as measured",
+ )
+
# How far through review this reading is, on USGS terms: provisional,
# in review, approved. Orthogonal to `release_status`, which says who may
# see it -- a reading can be public and provisional at once, which one
diff --git a/docs/hydrograph-correction-publish.md b/docs/hydrograph-correction-publish.md
new file mode 100644
index 000000000..572e4485f
--- /dev/null
+++ b/docs/hydrograph-correction-publish.md
@@ -0,0 +1,110 @@
+# Hydrograph correction — publish and range delete
+
+The hydrograph corrector in OcotilloUI (`/ocotillo/hydrograph-correction`)
+ingests a raw logger file, converts water head to depth below ground surface
+against manual measurements, applies corrections, and publishes the result
+here. This document records what the API side actually does; the UI-side
+proposal it was built from is
+`OcotilloUI/docs/hydrograph-correction-upload-contract.md`.
+
+## Authorization
+
+Both write routes are gated on **`AMP.Staging`**, a standalone Authentik group.
+It is not a fourth rung on the AMP ladder: `AMPAdmin` does not satisfy it, and
+it satisfies nothing else. Nobody holds it until it is granted, so the routes
+ship dark and are reachable only by whoever is validating the workbench against
+real logger files.
+
+When the workbench is trusted, these routes move to `amp_admin_dependency` and
+the group goes away. Leaving it as a tier would make that a schema change
+instead of a one-line edit.
+
+The read route stays on `amp_viewer_dependency` — it was already public to
+viewers and publishing does not change who may look.
+
+## `POST /observation/transducer-groundwater-level/block`
+
+One corrected logger file becomes one block plus all of its readings, in one
+transaction.
+
+- **The span is derived, not sent.** `start_datetime`/`end_datetime` come from
+ the min/max measurement timestamp. A client-supplied span wider than the data
+ would make the block claim readings it does not contain, because nothing links
+ the observation table to the block table — the reader pairs them by time.
+- **`deployment_id` is optional.** Omitted, it is resolved from the deployments
+ on the well whose installation period covers the span. A NULL installation date
+ reads as "always installed", a NULL removal date as "still installed". Zero or
+ more than one match is a 422 telling the client to send it explicitly, because
+ guessing attributes readings to hardware that did not record them.
+- **`data_maturity` is derived from `review_status`**, not sent: a block
+ published as `not reviewed` is `provisional` on USGS terms. Sending both
+ separately would let a client store a contradiction.
+- **Provenance is part of the record.** `source_file`, `source_kind`, and the
+ ordered `corrections` list live on the block; `provenance.notes` lands in the
+ block's existing `comment`. A reviewer who cannot see that a series was
+ snapped to a manual measurement cannot review it.
+- **Per-reading `note`** is set only where a correction moved the value, so NULL
+ means "as measured" rather than "unknown".
+
+### Overlap
+
+An existing block for the same well and parameter whose span shares any instant
+with the new one is a **409** listing the collisions in
+`detail[0].input.overlapping_blocks`. `?replace_overlapping=true` deletes those
+blocks **and their readings** in the same transaction and then publishes.
+
+The readings have to go with the block. Keeping them would leave rows the reader
+cannot show — no block covers them — that still occupy the
+deployment/parameter/instant the new series is about to claim, so a "replace"
+that kept them would fail on the very insert it was asked to make room for.
+
+Overlap here is **inclusive on both bounds**, unlike
+`TransducerObservationBlock.overlaps` on the model, which is half-open. The
+reader matches a reading to a block with `start <= t <= end`, so two blocks
+sharing an endpoint both claim any reading at that instant — exactly the
+ambiguity this check exists to prevent.
+
+Readings can also survive a block deleted by hand. Those are caught separately
+and reported as a 409 naming the earliest colliding timestamp, rather than
+letting the insert abort the transaction with a constraint name.
+
+## `DELETE /observation/transducer-groundwater-level`
+
+`thing_id`, `start_time`, and `end_time` are all required. There is deliberately
+no unbounded form of this request. The scope matches the `GET` on the same path
+exactly, so the set a client previews is the set this removes.
+
+Blocks are reconciled afterwards: one left with no readings is deleted, one left
+with some has its span narrowed to the survivors. A block narrowed to a single
+reading becomes zero-width, which the `end_datetime >= start_datetime` check
+constraint allows on purpose (migration `c3d4e5f6a7b8`) and which the inclusive
+reader still covers.
+
+**This leaves the `transducer_daily_data` materialized view stale** until its
+next scheduled refresh. Nothing here refreshes it — a full refresh on every
+delete would cost far more than the correctness it buys between nightly runs.
+
+## Two things fixed in passing
+
+- The read route was calling `get_transducer_observations` positionally, and the
+ helper's fourth positional parameter is `sensor_id`. `start_time` was landing
+ in `sensor_id` (unused, silently dropped), `end_time` was landing in
+ `start_time`, and `end_time` was never set — so an upper bound a caller asked
+ for was ignored and the lower bound came from the wrong argument. The call is
+ keyword-only now.
+- The read route honours `sort` (`observation_datetime`, `value`, `id`) and
+ `order` (`asc`/`desc`), defaulting to newest first. An unrecognised sort field
+ is a 422 rather than being ignored — silently returning a differently ordered
+ page reads as the data changing, not as a bad request.
+
+## Not built
+
+Everything in the contract's "Supporting endpoints for Wellntel ingestion"
+section is deferred: the `GET /wellntel/readings` proxy and the `sensor_type`
+filter on `GET /thing`. Both are blocked on open questions the contract itself
+raises — where the Wellntel API key lives and where the wellname→PointID mapping
+belongs. The UI already falls back to demo data when they are absent.
+
+Also open, and unchanged by this work: whether the raw water-head series should
+be retained alongside the corrected one, and whether publishing as `provisional`
+should feed a review queue.
diff --git a/domain/hydrograph.py b/domain/hydrograph.py
new file mode 100644
index 000000000..20ad1c7ed
--- /dev/null
+++ b/domain/hydrograph.py
@@ -0,0 +1,163 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Rules for publishing and deleting corrected transducer series.
+
+The hydrograph corrector (OcotilloUI) uploads a whole corrected file as one
+block. These functions decide the block's span, whether it collides with what is
+already stored, which deployment it belongs to, and what survives a range
+delete. They take plain values -- no session, no request -- so the awkward parts
+(inclusive vs half-open overlap, a block narrowed to a single instant) can be
+tested without a database.
+
+**Overlap is inclusive on both bounds here**, which differs from
+``TransducerObservationBlock.overlaps`` on the model. That method is half-open,
+so two blocks sharing an endpoint do not "overlap". The block *reader*
+(``services.observation_helper.get_transducer_observations``) matches an
+observation to a block with ``start <= t <= end``, so two blocks sharing an
+endpoint both claim any reading at that instant and the reader picks whichever
+sorts first. That ambiguity is the thing the publish conflict check exists to
+prevent, so the check uses the reader's inclusive bounds rather than the
+model's.
+"""
+
+from datetime import date, datetime
+
+# One request per logger file is the expected shape; a 90-day file at a 6-hour
+# cadence is 360 rows. The cap is three orders of magnitude above that, high
+# enough never to reject real work and low enough that a runaway client cannot
+# ask the server to build a million-row transaction.
+MAX_MEASUREMENTS = 100_000
+
+
+class HydrographError(ValueError):
+ """Base for publish/delete rule violations. A ValueError, per ADR4."""
+
+
+def derive_block_span(
+ observation_datetimes: list[datetime],
+) -> tuple[datetime, datetime]:
+ """
+ The block's span is the extent of its readings.
+
+ The client does not send ``start_datetime``/``end_datetime``: a span wider
+ than the data would claim coverage the block does not have, and the reader
+ would attach unrelated readings to it.
+ """
+ if not observation_datetimes:
+ raise HydrographError("A block needs at least one measurement")
+
+ return min(observation_datetimes), max(observation_datetimes)
+
+
+def first_out_of_order_index(observation_datetimes: list[datetime]) -> int | None:
+ """
+ Index of the first timestamp that does not advance on its predecessor.
+
+ Strictly increasing, so a repeated timestamp is reported too -- the storage
+ constraint is one reading per deployment/parameter/instant, and a duplicate
+ inside one request would abort the whole transaction on insert rather than
+ be reported against the row that caused it.
+
+ Returns the index so the caller can point at the offending row.
+ """
+ for index in range(1, len(observation_datetimes)):
+ if observation_datetimes[index] <= observation_datetimes[index - 1]:
+ return index
+
+ return None
+
+
+def spans_overlap(
+ a_start: datetime, a_end: datetime, b_start: datetime, b_end: datetime
+) -> bool:
+ """Whether two closed intervals share any instant. See the module docstring."""
+ return not (a_end < b_start or a_start > b_end)
+
+
+def resolve_deployment_id(
+ candidates: list[tuple[int, date | None, date | None]],
+ span_start: datetime,
+ span_end: datetime,
+) -> int:
+ """
+ Pick the deployment whose installation period covers a block's span.
+
+ ``candidates`` are ``(deployment_id, installation_date, removal_date)`` for
+ one well. A NULL installation date reads as "always installed" and a NULL
+ removal date as "still installed" -- that is how the column is used, and
+ treating an unrecorded date as a closed boundary would exclude the
+ deployments most likely to be current.
+
+ Ambiguity is an error rather than a choice: two overlapping deployments mean
+ two sensors could have produced the file, and guessing attributes readings to
+ hardware that did not record them.
+ """
+ span_start_date = span_start.date()
+ span_end_date = span_end.date()
+
+ covering = [
+ deployment_id
+ for deployment_id, installation_date, removal_date in candidates
+ if (installation_date is None or installation_date <= span_start_date)
+ and (removal_date is None or removal_date >= span_end_date)
+ ]
+
+ if not covering:
+ raise HydrographError(
+ f"No deployment covers {span_start_date} to {span_end_date}; "
+ "send deployment_id explicitly"
+ )
+ if len(covering) > 1:
+ joined = ", ".join(str(deployment_id) for deployment_id in sorted(covering))
+ raise HydrographError(
+ f"{len(covering)} deployments cover {span_start_date} to "
+ f"{span_end_date} ({joined}); send deployment_id explicitly"
+ )
+
+ return covering[0]
+
+
+def narrowed_block_span(
+ surviving_datetimes: list[datetime],
+) -> tuple[datetime, datetime] | None:
+ """
+ What a block's span becomes after some of its readings are deleted.
+
+ ``None`` means nothing survived and the block should go with them -- an
+ empty block is invisible to the reader and would only ever collide with a
+ later publish.
+ """
+ if not surviving_datetimes:
+ return None
+
+ return min(surviving_datetimes), max(surviving_datetimes)
+
+
+def validate_delete_range(start_time: datetime, end_time: datetime) -> None:
+ """
+ Reject a delete range that cannot be meant.
+
+ Both bounds are required by the route signature; this covers the ordering.
+ An inverted range is not silently reordered, because the operation is
+ irreversible and a transposed pair is as likely to be the wrong pair as the
+ right one written backwards.
+ """
+ if end_time <= start_time:
+ raise HydrographError("end_time must be after start_time")
+
+
+# ============= EOF =============================================
diff --git a/schemas/transducer.py b/schemas/transducer.py
index f11be79aa..a9c5c4142 100644
--- a/schemas/transducer.py
+++ b/schemas/transducer.py
@@ -14,10 +14,12 @@
# limitations under the License.
# ===============================================================================
from datetime import datetime
+from typing import Literal
-from pydantic import BaseModel
+from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator
-from core.enums import DataMaturity, ReviewStatus
+from core.enums import DataMaturity, ReleaseStatus, ReviewStatus
+from domain.hydrograph import MAX_MEASUREMENTS, first_out_of_order_index
from schemas import BaseResponseModel, BaseCreateModel
@@ -28,12 +30,22 @@ class TransducerObservationBlockResponse(BaseResponseModel):
parameter_id: int
# parameter: ParameterResponse
+ # Publish provenance. Nullable throughout: blocks loaded from the legacy
+ # AMPAPI transfer predate the corrector and state none of this.
+ source_file: str | None = None
+ source_kind: str | None = None
+ corrections: list[str] | None = None
+ comment: str | None = None
+
class TransducerObservationResponse(BaseResponseModel):
value: float
observation_datetime: datetime
parameter_id: int
deployment_id: int
+ # Set only where a correction moved the value, so NULL reads as
+ # "as measured" rather than "unknown".
+ note: str | None = None
# Nullable: readings loaded before the field existed do not state a
# maturity, and asserting one for them would be an invention.
data_maturity: DataMaturity | None
@@ -53,4 +65,118 @@ class CreateTransducerObservation(BaseCreateModel):
data_maturity: DataMaturity | None = None
+# ============= Hydrograph correction publish ====================
+# The corrector (OcotilloUI /ocotillo/hydrograph-correction) uploads one
+# corrected logger file as one block. See
+# docs/hydrograph-correction-publish.md.
+
+
+class TransducerBlockProvenance(BaseModel):
+ """Where a corrected series came from and what was done to it."""
+
+ source_file: str = Field(max_length=255)
+ source_kind: Literal["water_head", "depth_to_water"] | None = None
+ # Free text in applied order, written by the workbench: "shift (-1.25 ft,
+ # ...)", "snap_to_manual (+0.42 ft to ..., collected by ...)".
+ corrections: list[str] = Field(default_factory=list)
+ notes: str | None = None
+
+
+class CorrectedMeasurement(BaseModel):
+ """One reading of the corrected series, in feet below ground surface."""
+
+ # Aware, so a naive timestamp is rejected rather than guessed at. The
+ # workbench sends UTC; a logger file's local wall time silently read as UTC
+ # would shift a whole series by hours.
+ observation_datetime: AwareDatetime
+ value: float
+ note: str | None = None
+
+ # NaN and infinity are not measurements. Without this they would validate
+ # as floats and land in the column.
+ model_config = ConfigDict(allow_inf_nan=False)
+
+
+class PublishTransducerBlock(BaseCreateModel):
+ """A whole corrected file: one block plus every reading in it."""
+
+ thing_id: int
+ # Optional: resolved server-side from the block span when omitted, and 422
+ # when that is ambiguous. See domain.hydrograph.resolve_deployment_id.
+ deployment_id: int | None = None
+ parameter_id: int
+
+ # release_status comes from BaseCreateModel, defaulting to "draft".
+ review_status: ReviewStatus = "not reviewed"
+
+ provenance: TransducerBlockProvenance
+ measurements: list[CorrectedMeasurement] = Field(
+ min_length=1, max_length=MAX_MEASUREMENTS
+ )
+
+ @field_validator("review_status", mode="before")
+ @classmethod
+ def coerce_review_status(cls, v):
+ if isinstance(v, str):
+ try:
+ return ReviewStatus(v)
+ except ValueError:
+ raise ValueError(f"Invalid review_status: {v}")
+ return v
+
+ @field_validator("measurements")
+ @classmethod
+ def measurements_strictly_increasing(cls, measurements):
+ # Reported against the offending row's index so the UI can highlight
+ # it: the error path becomes
+ # ["body", "measurements", N, "observation_datetime"].
+ index = first_out_of_order_index(
+ [measurement.observation_datetime for measurement in measurements]
+ )
+ if index is not None:
+ raise ValueError(
+ f"measurements must be strictly increasing in time; row {index} "
+ f"({measurements[index].observation_datetime.isoformat()}) does not "
+ f"advance on row {index - 1} "
+ f"({measurements[index - 1].observation_datetime.isoformat()})"
+ )
+ return measurements
+
+
+class PublishedTransducerBlockResponse(BaseModel):
+ """
+ Mirrors the read shape so the client can merge a publish straight into a
+ ``GET /observation/transducer-groundwater-level`` result set. The
+ observations are not echoed -- the client just sent them; the count is what
+ it cannot know.
+ """
+
+ block: TransducerObservationBlockResponse
+ observation_count: int
+ thing_id: int
+ deployment_id: int
+
+
+class OverlappingBlock(BaseModel):
+ """An existing block a publish would collide with, named in the 409 body."""
+
+ id: int
+ start_datetime: datetime
+ end_datetime: datetime
+ review_status: ReviewStatus
+ release_status: ReleaseStatus
+
+
+class DeletedTransducerObservationsResponse(BaseModel):
+ """
+ What a range delete removed. ``updated_block_ids`` are blocks that kept
+ some readings and had their span narrowed to the survivors.
+ """
+
+ deleted_observation_count: int
+ deleted_block_ids: list[int]
+ updated_block_ids: list[int]
+ thing_id: int
+
+
# ============= EOF =============================================
diff --git a/services/observation_helper.py b/services/observation_helper.py
index 4e1cab5e6..e01735029 100644
--- a/services/observation_helper.py
+++ b/services/observation_helper.py
@@ -6,9 +6,9 @@
from fastapi import Request, Query
from fastapi_pagination.ext.sqlalchemy import paginate
from pydantic import BaseModel
-from sqlalchemy import select, desc
+from sqlalchemy import asc, select, desc
from sqlalchemy.orm import Session
-from starlette.status import HTTP_404_NOT_FOUND
+from starlette.status import HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_CONTENT
from db import (
Observation,
@@ -187,11 +187,54 @@ def transformer(observations):
return response_items
- query = query.order_by(TransducerObservation.observation_datetime.desc())
+ query = _sorted_transducer_query(query, sort, order)
return paginate(query=query, conn=session, transformer=transformer)
+# Only columns that mean something to a client of this endpoint. A whitelist
+# rather than getattr on the model: the latter would expose every column,
+# including the legacy `nma_*` ones, as a public sort key.
+_TRANSDUCER_SORT_COLUMNS = {
+ "observation_datetime": TransducerObservation.observation_datetime,
+ "value": TransducerObservation.value,
+ "id": TransducerObservation.id,
+}
+
+
+def _sorted_transducer_query(query, sort: str | None, order: str | None):
+ """
+ Apply `sort`/`order` to a transducer observation query.
+
+ Defaults to newest first, which is what the list view wants and what a
+ client asking for the latest stored reading relies on. An unrecognised sort
+ field is rejected rather than ignored -- silently returning a differently
+ ordered page reads as data changing, not as a bad request.
+ """
+ if sort and sort not in _TRANSDUCER_SORT_COLUMNS:
+ raise PydanticStyleException(
+ status_code=HTTP_422_UNPROCESSABLE_CONTENT,
+ detail=[
+ {
+ "loc": ["query", "sort"],
+ "msg": (
+ f"Cannot sort by '{sort}'. Valid fields: "
+ f"{', '.join(sorted(_TRANSDUCER_SORT_COLUMNS))}"
+ ),
+ "type": "value_error",
+ "input": sort,
+ }
+ ],
+ )
+
+ column = _TRANSDUCER_SORT_COLUMNS.get(
+ sort or "observation_datetime", TransducerObservation.observation_datetime
+ )
+ ascending = (order or "desc").lower() == "asc"
+
+ return query.order_by(asc(column) if ascending else desc(column))
+
+
def get_observations(
request: Request,
session: Session,
diff --git a/services/transducer_helper.py b/services/transducer_helper.py
new file mode 100644
index 000000000..1871fde4f
--- /dev/null
+++ b/services/transducer_helper.py
@@ -0,0 +1,459 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Publish and range-delete for corrected transducer series.
+
+Orchestration only: load the well, deployment, and colliding blocks, hand the
+decisions to ``domain.hydrograph``, persist the result. See
+``docs/hydrograph-correction-publish.md`` for the endpoint contract.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import delete, insert, select, update
+from sqlalchemy.orm import Session
+from starlette.status import (
+ HTTP_404_NOT_FOUND,
+ HTTP_409_CONFLICT,
+ HTTP_422_UNPROCESSABLE_CONTENT,
+)
+
+from db import Parameter, Thing
+from db.deployment import Deployment
+from db.transducer import TransducerObservation, TransducerObservationBlock
+from domain.hydrograph import (
+ HydrographError,
+ derive_block_span,
+ narrowed_block_span,
+ resolve_deployment_id,
+ validate_delete_range,
+)
+from schemas.transducer import (
+ DeletedTransducerObservationsResponse,
+ OverlappingBlock,
+ PublishedTransducerBlockResponse,
+ TransducerObservationBlockResponse,
+)
+from services.exceptions_helper import PydanticStyleException
+
+# A published block has not been reviewed by anyone yet, which on USGS terms is
+# exactly `provisional`. Once a reviewer marks the block `approved` the readings
+# follow. Derived rather than sent by the client so the two axes cannot be set
+# to contradict each other on the way in.
+_MATURITY_FOR_REVIEW_STATUS = {"approved": "approved"}
+_DEFAULT_MATURITY = "provisional"
+
+
+def _not_found(field: str, value, message: str):
+ return PydanticStyleException(
+ status_code=HTTP_404_NOT_FOUND,
+ detail=[
+ {
+ "loc": ["body", field],
+ "msg": message,
+ "type": "value_error",
+ "input": value,
+ }
+ ],
+ )
+
+
+def _unprocessable(loc: list, value, message: str):
+ return PydanticStyleException(
+ status_code=HTTP_422_UNPROCESSABLE_CONTENT,
+ detail=[
+ {
+ "loc": loc,
+ "msg": message,
+ "type": "value_error",
+ "input": value,
+ }
+ ],
+ )
+
+
+def _enum_value(value):
+ """Unwrap a lexicon-backed enum member to the term the column stores."""
+ return getattr(value, "value", value)
+
+
+def _deployment_ids_for_thing(session: Session, thing_id: int) -> list[int]:
+ return list(
+ session.scalars(
+ select(Deployment.id).where(Deployment.thing_id == thing_id)
+ ).all()
+ )
+
+
+def _overlapping_blocks(
+ session: Session,
+ thing_id: int,
+ parameter_id: int,
+ span_start: datetime,
+ span_end: datetime,
+) -> list[TransducerObservationBlock]:
+ """
+ Blocks whose closed span shares an instant with ``[span_start, span_end]``.
+
+ Inclusive on both ends, matching the block reader rather than
+ ``TransducerObservationBlock.overlaps``. See ``domain.hydrograph``.
+ """
+ return list(
+ session.scalars(
+ select(TransducerObservationBlock)
+ .where(
+ TransducerObservationBlock.thing_id == thing_id,
+ TransducerObservationBlock.parameter_id == parameter_id,
+ TransducerObservationBlock.start_datetime <= span_end,
+ TransducerObservationBlock.end_datetime >= span_start,
+ )
+ .order_by(TransducerObservationBlock.start_datetime)
+ ).all()
+ )
+
+
+def publish_transducer_block(
+ session: Session,
+ payload,
+ user=None,
+ replace_overlapping: bool = False,
+) -> PublishedTransducerBlockResponse:
+ """
+ Create one block and all of its readings, or nothing.
+
+ The block's span is derived from the readings -- a client-supplied span
+ wider than the data would attach unrelated readings to the block.
+ """
+ thing = session.get(Thing, payload.thing_id)
+ if thing is None:
+ raise _not_found(
+ "thing_id", payload.thing_id, f"Thing {payload.thing_id} not found"
+ )
+
+ parameter = session.get(Parameter, payload.parameter_id)
+ if parameter is None:
+ raise _not_found(
+ "parameter_id",
+ payload.parameter_id,
+ f"Parameter {payload.parameter_id} not found",
+ )
+
+ span_start, span_end = derive_block_span(
+ [measurement.observation_datetime for measurement in payload.measurements]
+ )
+
+ deployment_id = _resolve_deployment(session, payload, span_start, span_end)
+
+ existing = _overlapping_blocks(
+ session, payload.thing_id, payload.parameter_id, span_start, span_end
+ )
+ if existing and not replace_overlapping:
+ raise _overlap_conflict(existing)
+
+ if existing:
+ _delete_superseded(session, payload.thing_id, payload.parameter_id, existing)
+
+ # Readings can outlive the block that covered them -- nothing links the two
+ # tables, so a block deleted by hand leaves its observations behind, where
+ # the reader ignores them but the storage constraint still sees them. Insert
+ # would abort the transaction on the first collision with a message naming
+ # the constraint, so check first and say what is actually in the way.
+ _reject_colliding_observations(
+ session, deployment_id, payload.parameter_id, span_start, span_end
+ )
+
+ block = TransducerObservationBlock(
+ thing_id=payload.thing_id,
+ parameter_id=payload.parameter_id,
+ review_status=_enum_value(payload.review_status),
+ release_status=_enum_value(payload.release_status),
+ start_datetime=span_start,
+ end_datetime=span_end,
+ source_file=payload.provenance.source_file,
+ source_kind=payload.provenance.source_kind,
+ corrections=payload.provenance.corrections or None,
+ comment=payload.provenance.notes,
+ )
+ _stamp_created_by(block, user)
+ session.add(block)
+ session.flush()
+
+ review_status = _enum_value(payload.review_status)
+ data_maturity = _MATURITY_FOR_REVIEW_STATUS.get(review_status, _DEFAULT_MATURITY)
+ release_status = _enum_value(payload.release_status)
+ created_by_id, created_by_name = _created_by(user)
+
+ rows = [
+ {
+ "parameter_id": payload.parameter_id,
+ "deployment_id": deployment_id,
+ "observation_datetime": measurement.observation_datetime,
+ "value": measurement.value,
+ "note": measurement.note,
+ "data_maturity": data_maturity,
+ "release_status": release_status,
+ "created_by_id": created_by_id,
+ "created_by_name": created_by_name,
+ }
+ for measurement in payload.measurements
+ ]
+ session.execute(insert(TransducerObservation), rows)
+
+ session.commit()
+ session.refresh(block)
+
+ return PublishedTransducerBlockResponse(
+ block=TransducerObservationBlockResponse.model_validate(block),
+ observation_count=len(rows),
+ thing_id=payload.thing_id,
+ deployment_id=deployment_id,
+ )
+
+
+def _resolve_deployment(session, payload, span_start, span_end) -> int:
+ if payload.deployment_id is not None:
+ deployment = session.get(Deployment, payload.deployment_id)
+ if deployment is None:
+ raise _not_found(
+ "deployment_id",
+ payload.deployment_id,
+ f"Deployment {payload.deployment_id} not found",
+ )
+ if deployment.thing_id != payload.thing_id:
+ raise _unprocessable(
+ ["body", "deployment_id"],
+ payload.deployment_id,
+ f"Deployment {payload.deployment_id} belongs to thing "
+ f"{deployment.thing_id}, not {payload.thing_id}",
+ )
+ return payload.deployment_id
+
+ candidates = session.execute(
+ select(
+ Deployment.id, Deployment.installation_date, Deployment.removal_date
+ ).where(Deployment.thing_id == payload.thing_id)
+ ).all()
+
+ try:
+ return resolve_deployment_id(
+ [tuple(row) for row in candidates], span_start, span_end
+ )
+ except HydrographError as err:
+ raise _unprocessable(["body", "deployment_id"], None, str(err))
+
+
+def _overlap_conflict(blocks: list[TransducerObservationBlock]):
+ overlapping = [
+ OverlappingBlock.model_validate(block, from_attributes=True).model_dump(
+ mode="json"
+ )
+ for block in blocks
+ ]
+ ids = ", ".join(str(block.id) for block in blocks)
+ return PydanticStyleException(
+ status_code=HTTP_409_CONFLICT,
+ detail=[
+ {
+ "loc": ["body", "measurements"],
+ "msg": (
+ f"Time span overlaps existing block(s) {ids}. Retry with "
+ "?replace_overlapping=true to supersede them."
+ ),
+ "type": "value_error",
+ "input": {"overlapping_blocks": overlapping},
+ }
+ ],
+ )
+
+
+def _delete_superseded(
+ session: Session,
+ thing_id: int,
+ parameter_id: int,
+ blocks: list[TransducerObservationBlock],
+) -> None:
+ """
+ Drop the blocks a replacing publish supersedes, and their readings.
+
+ The readings go too. Keeping them would leave rows the reader cannot show
+ (no block covers them) that still occupy the deployment/parameter/instant
+ the new series is about to claim -- so "replace" that kept them would fail
+ on the very insert it was asked to make room for.
+ """
+ deployment_ids = _deployment_ids_for_thing(session, thing_id)
+ if deployment_ids:
+ for block in blocks:
+ session.execute(
+ delete(TransducerObservation).where(
+ TransducerObservation.deployment_id.in_(deployment_ids),
+ TransducerObservation.parameter_id == parameter_id,
+ TransducerObservation.observation_datetime >= block.start_datetime,
+ TransducerObservation.observation_datetime <= block.end_datetime,
+ )
+ )
+
+ session.execute(
+ delete(TransducerObservationBlock).where(
+ TransducerObservationBlock.id.in_([block.id for block in blocks])
+ )
+ )
+ session.flush()
+
+
+def _reject_colliding_observations(
+ session: Session,
+ deployment_id: int,
+ parameter_id: int,
+ span_start: datetime,
+ span_end: datetime,
+) -> None:
+ collisions = session.scalar(
+ select(TransducerObservation.observation_datetime)
+ .where(
+ TransducerObservation.deployment_id == deployment_id,
+ TransducerObservation.parameter_id == parameter_id,
+ TransducerObservation.observation_datetime >= span_start,
+ TransducerObservation.observation_datetime <= span_end,
+ )
+ .order_by(TransducerObservation.observation_datetime)
+ .limit(1)
+ )
+ if collisions is None:
+ return
+
+ raise PydanticStyleException(
+ status_code=HTTP_409_CONFLICT,
+ detail=[
+ {
+ "loc": ["body", "measurements"],
+ "msg": (
+ f"Deployment {deployment_id} already has readings in this "
+ f"time span (earliest {collisions.isoformat()}) that no block "
+ "covers. Delete them by range before publishing."
+ ),
+ "type": "value_error",
+ "input": {"deployment_id": deployment_id},
+ }
+ ],
+ )
+
+
+def delete_transducer_observations(
+ session: Session,
+ thing_id: int,
+ parameter_id: int,
+ start_time: datetime,
+ end_time: datetime,
+) -> DeletedTransducerObservationsResponse:
+ """
+ Delete every reading for a well inside a closed time range, then reconcile
+ the blocks that covered them.
+
+ Scoped exactly like the ``GET`` on the same path, so the set a client
+ previews is the set this removes. There is deliberately no unbounded form.
+ """
+ thing = session.get(Thing, thing_id)
+ if thing is None:
+ raise _not_found("thing_id", thing_id, f"Thing {thing_id} not found")
+
+ try:
+ validate_delete_range(start_time, end_time)
+ except HydrographError as err:
+ raise _unprocessable(["query", "end_time"], end_time.isoformat(), str(err))
+
+ deployment_ids = _deployment_ids_for_thing(session, thing_id)
+ if not deployment_ids:
+ return DeletedTransducerObservationsResponse(
+ deleted_observation_count=0,
+ deleted_block_ids=[],
+ updated_block_ids=[],
+ thing_id=thing_id,
+ )
+
+ # Read the affected blocks before deleting: after the readings are gone
+ # there is nothing left to identify which blocks covered them.
+ affected_blocks = _overlapping_blocks(
+ session, thing_id, parameter_id, start_time, end_time
+ )
+
+ deleted = session.execute(
+ delete(TransducerObservation).where(
+ TransducerObservation.deployment_id.in_(deployment_ids),
+ TransducerObservation.parameter_id == parameter_id,
+ TransducerObservation.observation_datetime >= start_time,
+ TransducerObservation.observation_datetime <= end_time,
+ )
+ )
+ deleted_observation_count = deleted.rowcount or 0
+ session.flush()
+
+ deleted_block_ids: list[int] = []
+ updated_block_ids: list[int] = []
+
+ for block in affected_blocks:
+ surviving = list(
+ session.scalars(
+ select(TransducerObservation.observation_datetime).where(
+ TransducerObservation.deployment_id.in_(deployment_ids),
+ TransducerObservation.parameter_id == parameter_id,
+ TransducerObservation.observation_datetime >= block.start_datetime,
+ TransducerObservation.observation_datetime <= block.end_datetime,
+ )
+ ).all()
+ )
+ span = narrowed_block_span(surviving)
+
+ if span is None:
+ deleted_block_ids.append(block.id)
+ session.execute(
+ delete(TransducerObservationBlock).where(
+ TransducerObservationBlock.id == block.id
+ )
+ )
+ continue
+
+ new_start, new_end = span
+ if (new_start, new_end) != (block.start_datetime, block.end_datetime):
+ updated_block_ids.append(block.id)
+ session.execute(
+ update(TransducerObservationBlock)
+ .where(TransducerObservationBlock.id == block.id)
+ .values(start_datetime=new_start, end_datetime=new_end)
+ )
+
+ session.commit()
+
+ return DeletedTransducerObservationsResponse(
+ deleted_observation_count=deleted_observation_count,
+ deleted_block_ids=deleted_block_ids,
+ updated_block_ids=updated_block_ids,
+ thing_id=thing_id,
+ )
+
+
+def _created_by(user) -> tuple[str | None, str | None]:
+ if isinstance(user, dict):
+ return user.get("sub"), user.get("name")
+ return None, None
+
+
+def _stamp_created_by(obj, user) -> None:
+ created_by_id, created_by_name = _created_by(user)
+ obj.created_by_id = created_by_id
+ obj.created_by_name = created_by_name
+
+
+# ============= EOF =============================================
diff --git a/tests/test_authorization.py b/tests/test_authorization.py
index 97608ae9c..02ce5caee 100644
--- a/tests/test_authorization.py
+++ b/tests/test_authorization.py
@@ -68,6 +68,7 @@
dependencies.amp_admin_function,
dependencies.amp_editor_function,
dependencies.amp_viewer_function,
+ dependencies.amp_staging_function,
dependencies.lexicon_admin_function,
dependencies.lexicon_editor_function,
dependencies.no_permission_function,
diff --git a/tests/test_domain_hydrograph.py b/tests/test_domain_hydrograph.py
new file mode 100644
index 000000000..70e857e13
--- /dev/null
+++ b/tests/test_domain_hydrograph.py
@@ -0,0 +1,179 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Rules behind publishing and range-deleting corrected transducer series."""
+
+from datetime import date, datetime, timedelta, timezone
+
+import pytest
+
+from domain.hydrograph import (
+ HydrographError,
+ derive_block_span,
+ first_out_of_order_index,
+ narrowed_block_span,
+ resolve_deployment_id,
+ spans_overlap,
+ validate_delete_range,
+)
+
+T0 = datetime(2025, 1, 15, tzinfo=timezone.utc)
+
+
+def _times(*hours):
+ return [T0 + timedelta(hours=h) for h in hours]
+
+
+# --------------------------------------------------------------------------
+# derive_block_span
+# --------------------------------------------------------------------------
+def test_span_is_the_extent_of_the_readings():
+ assert derive_block_span(_times(0, 6, 12)) == (T0, T0 + timedelta(hours=12))
+
+
+def test_span_does_not_assume_the_readings_arrived_sorted():
+ assert derive_block_span(_times(12, 0, 6)) == (T0, T0 + timedelta(hours=12))
+
+
+def test_a_single_reading_gives_a_zero_width_span():
+ # Allowed on purpose: the block check constraint is `end >= start` and the
+ # reader matches inclusively, so one reading still gets covered.
+ assert derive_block_span(_times(0)) == (T0, T0)
+
+
+def test_empty_series_is_rejected():
+ with pytest.raises(HydrographError):
+ derive_block_span([])
+
+
+# --------------------------------------------------------------------------
+# first_out_of_order_index
+# --------------------------------------------------------------------------
+def test_increasing_series_is_in_order():
+ assert first_out_of_order_index(_times(0, 6, 12)) is None
+
+
+def test_going_backwards_is_reported_at_the_offending_row():
+ assert first_out_of_order_index(_times(0, 12, 6)) == 2
+
+
+def test_a_repeated_timestamp_is_reported_too():
+ # Not merely untidy: two readings at one instant collide on the
+ # deployment/parameter/datetime constraint and would abort the insert.
+ assert first_out_of_order_index(_times(0, 6, 6)) == 2
+
+
+def test_a_single_row_cannot_be_out_of_order():
+ assert first_out_of_order_index(_times(0)) is None
+
+
+# --------------------------------------------------------------------------
+# spans_overlap
+# --------------------------------------------------------------------------
+def test_disjoint_spans_do_not_overlap():
+ assert not spans_overlap(
+ T0, T0 + timedelta(1), T0 + timedelta(2), T0 + timedelta(3)
+ )
+
+
+def test_nested_span_overlaps():
+ assert spans_overlap(
+ T0 + timedelta(1), T0 + timedelta(2), T0, T0 + timedelta(days=10)
+ )
+
+
+def test_spans_touching_at_one_instant_overlap():
+ # The distinguishing case. `TransducerObservationBlock.overlaps` is
+ # half-open and would call this clear; the reader is inclusive, so both
+ # blocks would claim a reading at the shared instant.
+ assert spans_overlap(T0, T0 + timedelta(1), T0 + timedelta(1), T0 + timedelta(2))
+
+
+# --------------------------------------------------------------------------
+# resolve_deployment_id
+# --------------------------------------------------------------------------
+SPAN_START = datetime(2025, 3, 1, tzinfo=timezone.utc)
+SPAN_END = datetime(2025, 3, 31, tzinfo=timezone.utc)
+
+
+def test_the_one_covering_deployment_is_chosen():
+ candidates = [
+ (1, date(2020, 1, 1), date(2021, 1, 1)),
+ (2, date(2025, 1, 1), None),
+ ]
+ assert resolve_deployment_id(candidates, SPAN_START, SPAN_END) == 2
+
+
+def test_a_null_installation_date_reads_as_always_installed():
+ assert resolve_deployment_id([(7, None, None)], SPAN_START, SPAN_END) == 7
+
+
+def test_a_deployment_removed_mid_span_does_not_cover_it():
+ with pytest.raises(HydrographError, match="No deployment covers"):
+ resolve_deployment_id(
+ [(1, date(2025, 1, 1), date(2025, 3, 15))], SPAN_START, SPAN_END
+ )
+
+
+def test_no_deployments_at_all_is_an_error_naming_the_way_out():
+ with pytest.raises(HydrographError, match="send deployment_id explicitly"):
+ resolve_deployment_id([], SPAN_START, SPAN_END)
+
+
+def test_two_covering_deployments_are_ambiguous_rather_than_a_coin_flip():
+ with pytest.raises(HydrographError, match="2 deployments cover"):
+ resolve_deployment_id(
+ [(1, date(2024, 1, 1), None), (2, None, None)], SPAN_START, SPAN_END
+ )
+
+
+# --------------------------------------------------------------------------
+# narrowed_block_span
+# --------------------------------------------------------------------------
+def test_a_block_with_nothing_left_is_marked_for_deletion():
+ assert narrowed_block_span([]) is None
+
+
+def test_a_block_narrows_to_what_survived():
+ assert narrowed_block_span(_times(6, 12)) == (
+ T0 + timedelta(hours=6),
+ T0 + timedelta(hours=12),
+ )
+
+
+def test_a_block_down_to_one_reading_becomes_zero_width():
+ assert narrowed_block_span(_times(6)) == (
+ T0 + timedelta(hours=6),
+ T0 + timedelta(hours=6),
+ )
+
+
+# --------------------------------------------------------------------------
+# validate_delete_range
+# --------------------------------------------------------------------------
+def test_a_forward_range_is_accepted():
+ assert validate_delete_range(T0, T0 + timedelta(1)) is None
+
+
+def test_an_inverted_range_is_rejected_not_reordered():
+ # The operation is irreversible; a transposed pair is as likely to be the
+ # wrong pair as the right one written backwards.
+ with pytest.raises(HydrographError, match="after start_time"):
+ validate_delete_range(T0 + timedelta(1), T0)
+
+
+def test_a_zero_width_range_is_rejected():
+ with pytest.raises(HydrographError):
+ validate_delete_range(T0, T0)
diff --git a/tests/test_transducer_publish.py b/tests/test_transducer_publish.py
new file mode 100644
index 000000000..9d20a5708
--- /dev/null
+++ b/tests/test_transducer_publish.py
@@ -0,0 +1,610 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+The hydrograph corrector's publish and range-delete endpoints.
+
+Both are gated on `AMP.Staging`, which nobody holds in Authentik yet -- these
+tests override that dependency, so they cover the behaviour, not the grant.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import select
+
+from core.dependencies import amp_staging_function, amp_viewer_function
+from db import Deployment, Sensor, Thing, TransducerObservation
+from db.engine import session_ctx
+from db.transducer import TransducerObservationBlock
+from main import app
+from tests import client, get_parameter_id, override_authentication
+
+PUBLISH_URL = "/observation/transducer-groundwater-level/block"
+READ_URL = "/observation/transducer-groundwater-level"
+
+T0 = datetime(2025, 1, 15, tzinfo=timezone.utc)
+
+
+def _groundwater_level_parameter_id() -> int:
+ return get_parameter_id("groundwater level", "Field Parameter")
+
+
+@pytest.fixture(scope="module", autouse=True)
+def override_authentication_dependency_fixture():
+ app.dependency_overrides[amp_staging_function] = override_authentication(
+ default={"name": "foobar", "sub": "1234567890"}
+ )
+ app.dependency_overrides[amp_viewer_function] = override_authentication()
+
+ yield
+
+ app.dependency_overrides = {}
+
+
+@pytest.fixture()
+def published_well():
+ """A well with one deployment and nothing stored yet."""
+ with session_ctx() as session:
+ thing = Thing(
+ name=f"Hydrograph Publish Well {datetime.now().timestamp()}",
+ first_visit_date="2023-03-03",
+ thing_type="water well",
+ release_status="draft",
+ well_depth=200,
+ hole_depth=200,
+ well_casing_diameter=5.0,
+ well_casing_depth=200.0,
+ )
+ sensor = Sensor(
+ name=f"Hydrograph Publish Sensor {datetime.now().timestamp()}",
+ sensor_type="Pressure Transducer",
+ model="Model X",
+ serial_no=f"serial-{datetime.now().timestamp()}",
+ pcn_number=f"pcn-{datetime.now().timestamp()}",
+ owner_agency="NMBGMR",
+ sensor_status="In Service",
+ release_status="draft",
+ )
+ session.add_all([thing, sensor])
+ session.flush()
+
+ deployment = Deployment(
+ sensor_id=sensor.id,
+ thing_id=thing.id,
+ installation_date="2020-01-01",
+ removal_date=None,
+ recording_interval=6,
+ recording_interval_units="hour",
+ )
+ session.add(deployment)
+ session.commit()
+
+ thing_id, deployment_id, sensor_id = thing.id, deployment.id, sensor.id
+
+ yield thing_id, deployment_id
+
+ with session_ctx() as session:
+ deployment_ids = session.scalars(
+ select(Deployment.id).where(Deployment.thing_id == thing_id)
+ ).all()
+ if deployment_ids:
+ for observation in session.scalars(
+ select(TransducerObservation).where(
+ TransducerObservation.deployment_id.in_(deployment_ids)
+ )
+ ).all():
+ session.delete(observation)
+ for block in session.scalars(
+ select(TransducerObservationBlock).where(
+ TransducerObservationBlock.thing_id == thing_id
+ )
+ ).all():
+ session.delete(block)
+ session.flush()
+ for model, pk in ((Deployment, deployment_id), (Thing, thing_id)):
+ obj = session.get(model, pk)
+ if obj is not None:
+ session.delete(obj)
+ session.flush()
+ sensor = session.get(Sensor, sensor_id)
+ if sensor is not None:
+ session.delete(sensor)
+ session.commit()
+
+
+def _payload(thing_id, hours=(0, 6, 12), **overrides):
+ payload = {
+ "thing_id": thing_id,
+ "parameter_id": _groundwater_level_parameter_id(),
+ "release_status": "provisional",
+ "review_status": "not reviewed",
+ "provenance": {
+ "source_file": "SO-0167_20250115.csv",
+ "source_kind": "water_head",
+ "corrections": ["convert_water_head (drift corrected)"],
+ "notes": "Snapped to 2025-01-15 manual measurement.",
+ },
+ "measurements": [
+ {
+ "observation_datetime": (T0 + timedelta(hours=h)).isoformat(),
+ "value": 42.5 + index * 0.01,
+ }
+ for index, h in enumerate(hours)
+ ],
+ }
+ payload.update(overrides)
+ return payload
+
+
+# --------------------------------------------------------------------------
+# publish
+# --------------------------------------------------------------------------
+def test_publish_creates_one_block_and_all_of_its_readings(published_well):
+ thing_id, deployment_id = published_well
+
+ response = client.post(PUBLISH_URL, json=_payload(thing_id))
+
+ assert response.status_code == 201, response.text
+ body = response.json()
+ assert body["observation_count"] == 3
+ assert body["thing_id"] == thing_id
+ # Deployment resolved server-side: the payload never named one.
+ assert body["deployment_id"] == deployment_id
+
+ block = body["block"]
+ assert block["release_status"] == "provisional"
+ assert block["review_status"] == "not reviewed"
+ assert block["source_file"] == "SO-0167_20250115.csv"
+ assert block["corrections"] == ["convert_water_head (drift corrected)"]
+ assert block["comment"] == "Snapped to 2025-01-15 manual measurement."
+
+ # Span is derived from the data, not sent by the client.
+ assert block["start_datetime"] == "2025-01-15T00:00:00Z"
+ assert block["end_datetime"] == "2025-01-15T12:00:00Z"
+
+
+def test_published_readings_come_back_from_the_read_endpoint(published_well):
+ thing_id, _ = published_well
+ client.post(PUBLISH_URL, json=_payload(thing_id))
+
+ response = client.get(READ_URL, params={"thing_id": thing_id})
+
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert len(items) == 3
+ # Newest first by default, so a client can ask for the latest with size 1.
+ assert items[0]["observation"]["observation_datetime"] == "2025-01-15T12:00:00Z"
+ # Unreviewed on publish is provisional on USGS terms.
+ assert items[0]["observation"]["data_maturity"] == "provisional"
+
+
+def test_per_reading_notes_are_persisted(published_well):
+ thing_id, _ = published_well
+ payload = _payload(thing_id)
+ payload["measurements"][1]["note"] = "spurious reflection removed"
+
+ client.post(PUBLISH_URL, json=payload)
+
+ response = client.get(READ_URL, params={"thing_id": thing_id, "order": "asc"})
+ observations = [item["observation"] for item in response.json()["items"]]
+ assert observations[0]["note"] is None
+ assert observations[1]["note"] == "spurious reflection removed"
+
+
+def test_a_single_reading_publishes_as_a_zero_width_block(published_well):
+ thing_id, _ = published_well
+
+ response = client.post(PUBLISH_URL, json=_payload(thing_id, hours=(0,)))
+
+ assert response.status_code == 201, response.text
+ block = response.json()["block"]
+ assert block["start_datetime"] == block["end_datetime"]
+
+
+def test_overlapping_publish_is_rejected_and_names_the_blocks(published_well):
+ thing_id, _ = published_well
+ first = client.post(PUBLISH_URL, json=_payload(thing_id))
+ first_block_id = first.json()["block"]["id"]
+
+ response = client.post(PUBLISH_URL, json=_payload(thing_id, hours=(6, 18)))
+
+ assert response.status_code == 409
+ detail = response.json()["detail"][0]
+ assert str(first_block_id) in detail["msg"]
+ assert detail["input"]["overlapping_blocks"][0]["id"] == first_block_id
+
+
+def test_replace_overlapping_supersedes_the_old_block_and_its_readings(
+ published_well,
+):
+ thing_id, _ = published_well
+ first = client.post(PUBLISH_URL, json=_payload(thing_id))
+ first_block_id = first.json()["block"]["id"]
+
+ response = client.post(
+ PUBLISH_URL,
+ params={"replace_overlapping": "true"},
+ json=_payload(thing_id, hours=(6, 18)),
+ )
+
+ assert response.status_code == 201, response.text
+ assert response.json()["block"]["id"] != first_block_id
+
+ with session_ctx() as session:
+ assert session.get(TransducerObservationBlock, first_block_id) is None
+
+ # Only the replacing series survives -- the superseded readings went with
+ # their block rather than being left where no block covers them.
+ items = client.get(READ_URL, params={"thing_id": thing_id}).json()["items"]
+ assert len(items) == 2
+
+
+def test_publish_is_atomic_when_a_reading_collides(published_well):
+ thing_id, _ = published_well
+ client.post(PUBLISH_URL, json=_payload(thing_id))
+
+ # Delete the block but leave its readings, which is what a hand-deleted
+ # block leaves behind: rows the reader ignores but storage still holds.
+ with session_ctx() as session:
+ for block in session.scalars(
+ select(TransducerObservationBlock).where(
+ TransducerObservationBlock.thing_id == thing_id
+ )
+ ).all():
+ session.delete(block)
+ session.commit()
+
+ response = client.post(PUBLISH_URL, json=_payload(thing_id))
+
+ assert response.status_code == 409
+ assert "no block covers" in response.json()["detail"][0]["msg"]
+
+ with session_ctx() as session:
+ blocks = session.scalars(
+ select(TransducerObservationBlock).where(
+ TransducerObservationBlock.thing_id == thing_id
+ )
+ ).all()
+ assert blocks == []
+
+
+def test_unknown_thing_is_a_404(published_well):
+ response = client.post(PUBLISH_URL, json=_payload(-1))
+ assert response.status_code == 404
+
+
+def test_out_of_order_measurements_point_at_the_offending_row(published_well):
+ thing_id, _ = published_well
+ payload = _payload(thing_id, hours=(0, 12, 6))
+
+ response = client.post(PUBLISH_URL, json=payload)
+
+ assert response.status_code == 422
+ assert any(
+ error["loc"][:3] == ["body", "measurements"]
+ for error in response.json()["detail"]
+ )
+
+
+def test_naive_timestamps_are_rejected(published_well):
+ thing_id, _ = published_well
+ payload = _payload(thing_id)
+ payload["measurements"][0]["observation_datetime"] = "2025-01-15T00:00:00"
+
+ response = client.post(PUBLISH_URL, json=payload)
+
+ assert response.status_code == 422
+
+
+def test_an_empty_series_is_rejected(published_well):
+ thing_id, _ = published_well
+ response = client.post(PUBLISH_URL, json=_payload(thing_id, hours=()))
+ assert response.status_code == 422
+
+
+def test_a_deployment_on_another_well_is_rejected(published_well, sensor):
+ thing_id, _ = published_well
+ with session_ctx() as session:
+ other = Thing(
+ name=f"Hydrograph Other Well {datetime.now().timestamp()}",
+ first_visit_date="2023-03-03",
+ thing_type="water well",
+ release_status="draft",
+ )
+ session.add(other)
+ session.flush()
+ other_deployment = Deployment(
+ sensor_id=sensor.id, thing_id=other.id, installation_date="2020-01-01"
+ )
+ session.add(other_deployment)
+ session.commit()
+ other_deployment_id, other_thing_id = other_deployment.id, other.id
+
+ try:
+ response = client.post(
+ PUBLISH_URL,
+ json=_payload(thing_id, deployment_id=other_deployment_id),
+ )
+ assert response.status_code == 422
+ assert "belongs to thing" in response.json()["detail"][0]["msg"]
+ finally:
+ with session_ctx() as session:
+ session.delete(session.get(Deployment, other_deployment_id))
+ session.flush()
+ session.delete(session.get(Thing, other_thing_id))
+ session.commit()
+
+
+# --------------------------------------------------------------------------
+# range delete
+# --------------------------------------------------------------------------
+def test_deleting_the_whole_span_removes_the_block_too(published_well):
+ thing_id, _ = published_well
+ block_id = client.post(PUBLISH_URL, json=_payload(thing_id)).json()["block"]["id"]
+
+ response = client.request(
+ "DELETE",
+ READ_URL,
+ params={
+ "thing_id": thing_id,
+ "start_time": T0.isoformat(),
+ "end_time": (T0 + timedelta(hours=12)).isoformat(),
+ },
+ )
+
+ assert response.status_code == 200, response.text
+ body = response.json()
+ assert body["deleted_observation_count"] == 3
+ assert body["deleted_block_ids"] == [block_id]
+ assert body["updated_block_ids"] == []
+ assert client.get(READ_URL, params={"thing_id": thing_id}).json()["items"] == []
+
+
+def test_a_partial_delete_narrows_the_block_to_the_survivors(published_well):
+ thing_id, _ = published_well
+ block_id = client.post(PUBLISH_URL, json=_payload(thing_id)).json()["block"]["id"]
+
+ response = client.request(
+ "DELETE",
+ READ_URL,
+ params={
+ "thing_id": thing_id,
+ "start_time": (T0 + timedelta(hours=6)).isoformat(),
+ "end_time": (T0 + timedelta(hours=12)).isoformat(),
+ },
+ )
+
+ assert response.status_code == 200, response.text
+ body = response.json()
+ assert body["deleted_observation_count"] == 2
+ assert body["deleted_block_ids"] == []
+ assert body["updated_block_ids"] == [block_id]
+
+ with session_ctx() as session:
+ block = session.get(TransducerObservationBlock, block_id)
+ assert block.start_datetime == T0
+ assert block.end_datetime == T0
+
+ # The survivor is still readable, which it would not be if the narrowed
+ # block no longer covered it.
+ items = client.get(READ_URL, params={"thing_id": thing_id}).json()["items"]
+ assert len(items) == 1
+
+
+def test_an_inverted_delete_range_is_rejected(published_well):
+ thing_id, _ = published_well
+
+ response = client.request(
+ "DELETE",
+ READ_URL,
+ params={
+ "thing_id": thing_id,
+ "start_time": (T0 + timedelta(hours=12)).isoformat(),
+ "end_time": T0.isoformat(),
+ },
+ )
+
+ assert response.status_code == 422
+
+
+def test_delete_without_a_bound_is_rejected(published_well):
+ thing_id, _ = published_well
+
+ response = client.request("DELETE", READ_URL, params={"thing_id": thing_id})
+
+ assert response.status_code == 422
+
+
+def test_delete_for_an_unknown_well_is_a_404():
+ response = client.request(
+ "DELETE",
+ READ_URL,
+ params={
+ "thing_id": -1,
+ "start_time": T0.isoformat(),
+ "end_time": (T0 + timedelta(hours=12)).isoformat(),
+ },
+ )
+
+ assert response.status_code == 404
+
+
+# --------------------------------------------------------------------------
+# read ordering
+# --------------------------------------------------------------------------
+def test_ascending_order_is_honoured(published_well):
+ thing_id, _ = published_well
+ client.post(PUBLISH_URL, json=_payload(thing_id))
+
+ items = client.get(READ_URL, params={"thing_id": thing_id, "order": "asc"}).json()[
+ "items"
+ ]
+
+ assert items[0]["observation"]["observation_datetime"] == "2025-01-15T00:00:00Z"
+
+
+def test_an_unknown_sort_field_is_rejected_rather_than_ignored(published_well):
+ thing_id, _ = published_well
+
+ response = client.get(READ_URL, params={"thing_id": thing_id, "sort": "nonsense"})
+
+ assert response.status_code == 422
+
+
+# --------------------------------------------------------------------------
+# deployment resolution at the service boundary
+#
+# domain.hydrograph covers the rule itself; these cover the translation of its
+# verdicts into responses, and the path where the client names a deployment
+# and the rule never runs.
+# --------------------------------------------------------------------------
+def test_an_explicitly_named_deployment_is_used_as_given(published_well):
+ thing_id, deployment_id = published_well
+
+ response = client.post(
+ PUBLISH_URL, json=_payload(thing_id, deployment_id=deployment_id)
+ )
+
+ assert response.status_code == 201, response.text
+ assert response.json()["deployment_id"] == deployment_id
+
+
+def test_unknown_deployment_is_a_404(published_well):
+ thing_id, _ = published_well
+
+ response = client.post(PUBLISH_URL, json=_payload(thing_id, deployment_id=-1))
+
+ assert response.status_code == 404
+ assert response.json()["detail"][0]["loc"] == ["body", "deployment_id"]
+
+
+def test_unknown_parameter_is_a_404(published_well):
+ thing_id, _ = published_well
+
+ response = client.post(PUBLISH_URL, json=_payload(thing_id, parameter_id=-1))
+
+ assert response.status_code == 404
+ assert response.json()["detail"][0]["loc"] == ["body", "parameter_id"]
+
+
+def test_two_covering_deployments_are_a_422_not_a_coin_flip(published_well, sensor):
+ thing_id, _ = published_well
+ with session_ctx() as session:
+ second = Deployment(
+ sensor_id=sensor.id,
+ thing_id=thing_id,
+ installation_date="2019-01-01",
+ removal_date=None,
+ )
+ session.add(second)
+ session.commit()
+ second_id = second.id
+
+ try:
+ response = client.post(PUBLISH_URL, json=_payload(thing_id))
+
+ assert response.status_code == 422
+ detail = response.json()["detail"][0]
+ assert detail["loc"] == ["body", "deployment_id"]
+ assert "2 deployments cover" in detail["msg"]
+ finally:
+ with session_ctx() as session:
+ session.delete(session.get(Deployment, second_id))
+ session.commit()
+
+
+def test_a_span_no_deployment_covers_is_a_422(published_well):
+ thing_id, deployment_id = published_well
+ # Retire the well's only deployment before the series was recorded.
+ with session_ctx() as session:
+ session.get(Deployment, deployment_id).removal_date = "2021-01-01"
+ session.commit()
+
+ try:
+ response = client.post(PUBLISH_URL, json=_payload(thing_id))
+
+ assert response.status_code == 422
+ assert "No deployment covers" in response.json()["detail"][0]["msg"]
+ finally:
+ with session_ctx() as session:
+ session.get(Deployment, deployment_id).removal_date = None
+ session.commit()
+
+
+def test_deleting_from_a_well_with_no_deployments_removes_nothing():
+ with session_ctx() as session:
+ thing = Thing(
+ name=f"Hydrograph Bare Well {datetime.now().timestamp()}",
+ first_visit_date="2023-03-03",
+ thing_type="water well",
+ release_status="draft",
+ )
+ session.add(thing)
+ session.commit()
+ thing_id = thing.id
+
+ try:
+ response = client.request(
+ "DELETE",
+ READ_URL,
+ params={
+ "thing_id": thing_id,
+ "start_time": T0.isoformat(),
+ "end_time": (T0 + timedelta(hours=12)).isoformat(),
+ },
+ )
+
+ assert response.status_code == 200, response.text
+ assert response.json() == {
+ "deleted_observation_count": 0,
+ "deleted_block_ids": [],
+ "updated_block_ids": [],
+ "thing_id": thing_id,
+ }
+ finally:
+ with session_ctx() as session:
+ session.delete(session.get(Thing, thing_id))
+ session.commit()
+
+
+def test_audit_stamping_survives_a_non_dict_user():
+ # `authenticated()` yields the token claims, but the development bypass
+ # yields `True`. Reachable in any environment running with
+ # AUTHENTIK_DISABLE_AUTHENTICATION, where a publish must still write rather
+ # than fail reaching into a bool for a subject id.
+ from services.transducer_helper import _created_by
+
+ assert _created_by(True) == (None, None)
+ assert _created_by({"sub": "1234567890", "name": "foobar"}) == (
+ "1234567890",
+ "foobar",
+ )
+
+
+def test_a_review_status_outside_the_lexicon_is_rejected(published_well):
+ # `review_status` is lexicon-backed, so an unknown term would otherwise
+ # reach the column and fail on a foreign key rather than as a bad request.
+ thing_id, _ = published_well
+
+ response = client.post(
+ PUBLISH_URL, json=_payload(thing_id, review_status="mostly reviewed")
+ )
+
+ assert response.status_code == 422
+ assert any(
+ error["loc"][:2] == ["body", "review_status"]
+ for error in response.json()["detail"]
+ )
From ba73c6b012c9c780cb3dc5c3d0bd67e559882867 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 15:49:21 -0700
Subject: [PATCH 118/151] fix(transducer): spell the block time-order
constraint correctly
The check was created as `check_transuder_block_time_order` -- no `c` -- in the
initial migration, so that is the name in every deployed database. Postgres
cannot alter a check in place, so the drop-and-recreate that relaxing it to
`end_datetime >= start_datetime` already required is the free moment to fix the
spelling: dropped under the old name, created under the new one, no separate
RENAME and no window where the table is unconstrained beyond the one the
relaxation already opens.
The two spellings are separate constants because they are not
interchangeable. `op.drop_constraint` matches on the name in the live database,
so anything reaching for the constraint to *find* it has to use the spelling
that matches the revision it is running against -- the old one on the way down,
the new one on the way up. Verified by round-tripping the migration against the
test database: downgrade restores `check_transuder_...` with `>`, upgrade
restores `check_transducer_...` with `>=`.
Co-Authored-By: Claude Opus 5
---
...4e5f6a7b8_hydrograph_correction_publish.py | 24 +++++++++++++++----
db/transducer.py | 2 +-
docs/hydrograph-correction-publish.md | 7 ++++++
3 files changed, 27 insertions(+), 6 deletions(-)
diff --git a/alembic/versions/c3d4e5f6a7b8_hydrograph_correction_publish.py b/alembic/versions/c3d4e5f6a7b8_hydrograph_correction_publish.py
index d3f20e363..48e6bebdf 100644
--- a/alembic/versions/c3d4e5f6a7b8_hydrograph_correction_publish.py
+++ b/alembic/versions/c3d4e5f6a7b8_hydrograph_correction_publish.py
@@ -24,6 +24,12 @@
matches observations inclusively on both bounds, so a zero-width block still
covers its reading. Loosening a check constraint cannot invalidate existing
rows.
+
+That check also gets its name spelled right on the way through. It was created
+as `check_transuder_block_time_order` -- no `c` -- and since Postgres cannot
+alter a check in place, the drop-and-recreate this migration already performs
+is the free moment to fix it. The old name is dropped and the new one created;
+no separate RENAME is needed.
"""
import sqlalchemy as sa
@@ -35,9 +41,14 @@
branch_labels = None
depends_on = None
-# Spelled as it exists in the database, typo included -- renaming it here would
-# leave deployed environments with a constraint this migration cannot find.
-TIME_ORDER_CONSTRAINT = "check_transuder_block_time_order"
+# The name as created by the initial migration, misspelled. Drops and
+# downgrades have to use it verbatim: `op.drop_constraint` matches on the name
+# in the live database, so correcting the spelling anywhere it is used to
+# *find* the constraint would make the statement a no-op target and fail.
+LEGACY_TIME_ORDER_CONSTRAINT = "check_transuder_block_time_order"
+
+# What it is called from this migration forward.
+TIME_ORDER_CONSTRAINT = "check_transducer_block_time_order"
def upgrade() -> None:
@@ -81,8 +92,11 @@ def upgrade() -> None:
),
)
+ # Dropped under the old name, recreated under the new one: the rename and
+ # the relaxation are the same statement pair, so there is no window where
+ # the table is unconstrained beyond the one this already needs.
op.drop_constraint(
- TIME_ORDER_CONSTRAINT, "transducer_observation_block", type_="check"
+ LEGACY_TIME_ORDER_CONSTRAINT, "transducer_observation_block", type_="check"
)
op.create_check_constraint(
TIME_ORDER_CONSTRAINT,
@@ -105,7 +119,7 @@ def downgrade() -> None:
TIME_ORDER_CONSTRAINT, "transducer_observation_block", type_="check"
)
op.create_check_constraint(
- TIME_ORDER_CONSTRAINT,
+ LEGACY_TIME_ORDER_CONSTRAINT,
"transducer_observation_block",
"end_datetime > start_datetime",
)
diff --git a/db/transducer.py b/db/transducer.py
index 65766f6e5..e129cfe60 100644
--- a/db/transducer.py
+++ b/db/transducer.py
@@ -114,7 +114,7 @@ class TransducerObservationBlock(Base, AutoBaseMixin, ReleaseMixin):
# observations inclusively on both bounds, so a zero-width block still
# covers its reading.
CheckConstraint(
- "end_datetime >= start_datetime", name="check_transuder_block_time_order"
+ "end_datetime >= start_datetime", name="check_transducer_block_time_order"
),
Index(
"ix_transducer_block_time",
diff --git a/docs/hydrograph-correction-publish.md b/docs/hydrograph-correction-publish.md
index 572e4485f..768b91559 100644
--- a/docs/hydrograph-correction-publish.md
+++ b/docs/hydrograph-correction-publish.md
@@ -80,6 +80,13 @@ reading becomes zero-width, which the `end_datetime >= start_datetime` check
constraint allows on purpose (migration `c3d4e5f6a7b8`) and which the inclusive
reader still covers.
+That same migration renames the constraint from `check_transuder_...` to
+`check_transducer_...`. Postgres cannot alter a check in place, so the
+drop-and-recreate the relaxation already required was the free moment to fix
+the spelling. The downgrade puts the old name back, so anything reaching for
+the constraint by name has to pick the spelling that matches the revision it is
+running against.
+
**This leaves the `transducer_daily_data` materialized view stale** until its
next scheduled refresh. Nothing here refreshes it — a full refresh on every
delete would cost far more than the correctness it buys between nightly runs.
From 4701979f8b4240e1bd1db53e820626476344e8df Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 22:48:27 -0700
Subject: [PATCH 119/151] fix(transducer): serialize series writes and scope
the publish parameter
Four review findings from the Copilot pass, all real.
The publish and range-delete paths each read state, decide, and then write
based on what they read, with nothing stopping another writer in between. Two
publishes with different timestamps but overlapping spans each saw no existing
block and both committed -- the unique constraints only catch identical spans
and identical readings -- leaving the inclusive reader with two blocks claiming
the same instants. Two range deletes each computed survivors from a snapshot
the other was invalidating, so the later update could widen a block back over
readings the earlier one had removed. Both now take a transaction-scoped
advisory lock on (thing_id, parameter_id) before reading.
An advisory lock rather than row locks because on publish there is no row to
lock: the conflict is with a block that does not exist yet, so what needs
guarding is the series itself. Both paths take the same key in the same order,
so they serialize against each other and cannot deadlock against one another.
Covered by a test that holds the lock from a second connection and asserts the
publish waits for it rather than proceeding on a stale snapshot.
`parameter_id` was accepted from the client unchecked while the read and delete
routes on the same path resolve groundwater level themselves, so publishing
under any other parameter returned 201 for data neither of them could ever list
or remove. The field stays -- the contract has the client state it explicitly
rather than inherit a server-side default -- but it is now validated against the
parameter the route is scoped to.
`order` accepted anything and silently fell through to descending, so the near
miss `order=ascending` returned 200 with the rows in exactly the opposite order
to the one requested. It is now a 422 in the same shape as an unknown sort
field.
Co-Authored-By: Claude Opus 5
---
api/observation.py | 6 ++-
docs/hydrograph-correction-publish.md | 31 +++++++++++-
services/observation_helper.py | 25 ++++++++-
services/transducer_helper.py | 58 ++++++++++++++++++---
tests/test_transducer_publish.py | 73 +++++++++++++++++++++++++--
5 files changed, 177 insertions(+), 16 deletions(-)
diff --git a/api/observation.py b/api/observation.py
index 4e084036c..fb11ca9d7 100644
--- a/api/observation.py
+++ b/api/observation.py
@@ -137,7 +137,11 @@ def publish_transducer_groundwater_level_block(
`docs/hydrograph-correction-publish.md`.
"""
return publish_transducer_block(
- session, payload, user=user, replace_overlapping=replace_overlapping
+ session,
+ payload,
+ parameter_id=_groundwater_level_parameter_id(session),
+ user=user,
+ replace_overlapping=replace_overlapping,
)
diff --git a/docs/hydrograph-correction-publish.md b/docs/hydrograph-correction-publish.md
index 768b91559..3d8a59fbb 100644
--- a/docs/hydrograph-correction-publish.md
+++ b/docs/hydrograph-correction-publish.md
@@ -46,6 +46,30 @@ transaction.
- **Per-reading `note`** is set only where a correction moved the value, so NULL
means "as measured" rather than "unknown".
+- **`parameter_id` is validated, not obeyed.** The client states it explicitly,
+ per the contract, but the route checks it against the parameter the route is
+ scoped to. The read and delete routes on this path resolve groundwater level
+ themselves, so a block accepted under any other parameter would be a 201 for
+ data neither of them could ever list or remove.
+
+### Concurrency
+
+Both write paths read state, decide, and then write based on what they read, so
+each takes a transaction-scoped advisory lock on `(thing_id, parameter_id)`
+first — `pg_advisory_xact_lock`.
+
+Without it, two publishes with different timestamps but overlapping spans each
+see no existing block and both commit: the unique constraints only catch
+identical spans and identical readings, and the inclusive reader then has two
+blocks claiming the same instants. Two range deletes each compute survivors from
+a snapshot the other is invalidating, and the later update can widen a block back
+over readings the earlier one removed.
+
+An advisory lock rather than row locks because on publish there is no row to
+lock — the conflict is with a block that does not exist yet — so what needs
+guarding is the series, not a row. Both paths take the same key, so they
+serialize against each other and cannot deadlock against one another.
+
### Overlap
An existing block for the same well and parameter whose span shares any instant
@@ -101,8 +125,11 @@ delete would cost far more than the correctness it buys between nightly runs.
keyword-only now.
- The read route honours `sort` (`observation_datetime`, `value`, `id`) and
`order` (`asc`/`desc`), defaulting to newest first. An unrecognised sort field
- is a 422 rather than being ignored — silently returning a differently ordered
- page reads as the data changing, not as a bad request.
+ or order is a 422 rather than being ignored — silently returning a differently
+ ordered page reads as the data changing, not as a bad request. `order` matters
+ particularly here: anything other than `asc` used to fall through to
+ descending, so the near-miss `order=ascending` returned 200 with the rows in
+ exactly the opposite order to the one asked for.
## Not built
diff --git a/services/observation_helper.py b/services/observation_helper.py
index e01735029..1afe11158 100644
--- a/services/observation_helper.py
+++ b/services/observation_helper.py
@@ -201,6 +201,8 @@ def transformer(observations):
"id": TransducerObservation.id,
}
+_TRANSDUCER_SORT_ORDERS = {"asc", "desc"}
+
def _sorted_transducer_query(query, sort: str | None, order: str | None):
"""
@@ -227,12 +229,31 @@ def _sorted_transducer_query(query, sort: str | None, order: str | None):
],
)
+ normalized_order = (order or "desc").lower()
+ if normalized_order not in _TRANSDUCER_SORT_ORDERS:
+ # Rejected for the same reason an unknown sort field is: anything other
+ # than `asc` used to fall through to descending, so `order=ascending`
+ # returned 200 with the opposite of what was asked for.
+ raise PydanticStyleException(
+ status_code=HTTP_422_UNPROCESSABLE_CONTENT,
+ detail=[
+ {
+ "loc": ["query", "order"],
+ "msg": (
+ f"Cannot order by '{order}'. Valid values: "
+ f"{', '.join(sorted(_TRANSDUCER_SORT_ORDERS))}"
+ ),
+ "type": "value_error",
+ "input": order,
+ }
+ ],
+ )
+
column = _TRANSDUCER_SORT_COLUMNS.get(
sort or "observation_datetime", TransducerObservation.observation_datetime
)
- ascending = (order or "desc").lower() == "asc"
- return query.order_by(asc(column) if ascending else desc(column))
+ return query.order_by(asc(column) if normalized_order == "asc" else desc(column))
def get_observations(
diff --git a/services/transducer_helper.py b/services/transducer_helper.py
index 1871fde4f..99a2e6b5d 100644
--- a/services/transducer_helper.py
+++ b/services/transducer_helper.py
@@ -23,7 +23,7 @@
from datetime import datetime
-from sqlalchemy import delete, insert, select, update
+from sqlalchemy import delete, insert, select, text, update
from sqlalchemy.orm import Session
from starlette.status import (
HTTP_404_NOT_FOUND,
@@ -31,7 +31,7 @@
HTTP_422_UNPROCESSABLE_CONTENT,
)
-from db import Parameter, Thing
+from db import Thing
from db.deployment import Deployment
from db.transducer import TransducerObservation, TransducerObservationBlock
from domain.hydrograph import (
@@ -90,6 +90,32 @@ def _enum_value(value):
return getattr(value, "value", value)
+def _lock_series(session: Session, thing_id: int, parameter_id: int) -> None:
+ """
+ Serialize writers against one well's series for the rest of the transaction.
+
+ Both the publish overlap check and the delete reconciliation read state,
+ decide, and then write -- and neither decision survives a concurrent writer.
+ Two publishes with different timestamps but overlapping spans each see no
+ existing block and both commit, because the unique constraints only catch
+ identical spans and identical readings; the inclusive reader then has two
+ blocks claiming the same instants. Two deletes each compute survivors from
+ a snapshot the other is invalidating, and the later update can widen a block
+ back over readings the earlier one removed.
+
+ An advisory lock rather than row locks: on publish there is no row to lock
+ yet -- the conflict is with a block that does not exist -- so the thing being
+ guarded is the (well, parameter) series itself, not any row. Transaction
+ scoped, so it releases on commit or rollback with no unlock path to forget.
+ Publish and delete take the same key in the same order, so they serialize
+ against each other and cannot deadlock against one another.
+ """
+ session.execute(
+ text("SELECT pg_advisory_xact_lock(:thing_id, :parameter_id)"),
+ {"thing_id": thing_id, "parameter_id": parameter_id},
+ )
+
+
def _deployment_ids_for_thing(session: Session, thing_id: int) -> list[int]:
return list(
session.scalars(
@@ -128,6 +154,7 @@ def _overlapping_blocks(
def publish_transducer_block(
session: Session,
payload,
+ parameter_id: int,
user=None,
replace_overlapping: bool = False,
) -> PublishedTransducerBlockResponse:
@@ -136,6 +163,10 @@ def publish_transducer_block(
The block's span is derived from the readings -- a client-supplied span
wider than the data would attach unrelated readings to the block.
+
+ ``parameter_id`` is the parameter this route is scoped to, resolved by the
+ caller; the payload's own ``parameter_id`` is checked against it rather than
+ trusted.
"""
thing = session.get(Thing, payload.thing_id)
if thing is None:
@@ -143,12 +174,19 @@ def publish_transducer_block(
"thing_id", payload.thing_id, f"Thing {payload.thing_id} not found"
)
- parameter = session.get(Parameter, payload.parameter_id)
- if parameter is None:
- raise _not_found(
- "parameter_id",
+ # The read and delete routes on this path resolve the groundwater level
+ # parameter themselves, so a block published under any other parameter would
+ # be invisible to both -- a 201 for data that can then never be listed or
+ # removed here. The field stays in the request because the contract has the
+ # client state it explicitly rather than inherit a server-side default; it
+ # is validated, not obeyed.
+ if payload.parameter_id != parameter_id:
+ raise _unprocessable(
+ ["body", "parameter_id"],
payload.parameter_id,
- f"Parameter {payload.parameter_id} not found",
+ f"This route publishes parameter {parameter_id} only; the read and "
+ f"delete routes on this path would not see parameter "
+ f"{payload.parameter_id}",
)
span_start, span_end = derive_block_span(
@@ -157,6 +195,10 @@ def publish_transducer_block(
deployment_id = _resolve_deployment(session, payload, span_start, span_end)
+ # Everything from here reads state and then writes based on it, so no other
+ # writer may touch this series until the transaction ends.
+ _lock_series(session, payload.thing_id, payload.parameter_id)
+
existing = _overlapping_blocks(
session, payload.thing_id, payload.parameter_id, span_start, span_end
)
@@ -374,6 +416,8 @@ def delete_transducer_observations(
except HydrographError as err:
raise _unprocessable(["query", "end_time"], end_time.isoformat(), str(err))
+ _lock_series(session, thing_id, parameter_id)
+
deployment_ids = _deployment_ids_for_thing(session, thing_id)
if not deployment_ids:
return DeletedTransducerObservationsResponse(
diff --git a/tests/test_transducer_publish.py b/tests/test_transducer_publish.py
index 9d20a5708..44e325b06 100644
--- a/tests/test_transducer_publish.py
+++ b/tests/test_transducer_publish.py
@@ -20,14 +20,15 @@
tests override that dependency, so they cover the behaviour, not the grant.
"""
+import threading
from datetime import datetime, timedelta, timezone
import pytest
-from sqlalchemy import select
+from sqlalchemy import select, text
from core.dependencies import amp_staging_function, amp_viewer_function
from db import Deployment, Sensor, Thing, TransducerObservation
-from db.engine import session_ctx
+from db.engine import engine, session_ctx
from db.transducer import TransducerObservationBlock
from main import app
from tests import client, get_parameter_id, override_authentication
@@ -464,6 +465,17 @@ def test_an_unknown_sort_field_is_rejected_rather_than_ignored(published_well):
assert response.status_code == 422
+def test_an_unknown_order_is_rejected_rather_than_silently_descending(published_well):
+ # `ascending` is the obvious near-miss for `asc`, and it used to return 200
+ # with the rows in exactly the opposite order to the one asked for.
+ thing_id, _ = published_well
+
+ response = client.get(READ_URL, params={"thing_id": thing_id, "order": "ascending"})
+
+ assert response.status_code == 422
+ assert response.json()["detail"][0]["loc"] == ["query", "order"]
+
+
# --------------------------------------------------------------------------
# deployment resolution at the service boundary
#
@@ -491,12 +503,28 @@ def test_unknown_deployment_is_a_404(published_well):
assert response.json()["detail"][0]["loc"] == ["body", "deployment_id"]
-def test_unknown_parameter_is_a_404(published_well):
+def test_a_parameter_this_route_does_not_serve_is_rejected(published_well):
+ # A block published under another parameter would be invisible to the read
+ # and delete routes on this path, which resolve groundwater level
+ # themselves -- a 201 for data that could then never be listed or removed
+ # here.
+ thing_id, _ = published_well
+ other_parameter_id = get_parameter_id("pH", "Field Parameter")
+
+ response = client.post(
+ PUBLISH_URL, json=_payload(thing_id, parameter_id=other_parameter_id)
+ )
+
+ assert response.status_code == 422
+ assert response.json()["detail"][0]["loc"] == ["body", "parameter_id"]
+
+
+def test_an_unknown_parameter_is_rejected_too(published_well):
thing_id, _ = published_well
response = client.post(PUBLISH_URL, json=_payload(thing_id, parameter_id=-1))
- assert response.status_code == 404
+ assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "parameter_id"]
@@ -608,3 +636,40 @@ def test_a_review_status_outside_the_lexicon_is_rejected(published_well):
error["loc"][:2] == ["body", "review_status"]
for error in response.json()["detail"]
)
+
+
+def test_publish_waits_for_a_concurrent_writer_on_the_same_series(published_well):
+ """
+ The overlap check reads state and then writes based on it, so it is only
+ correct if no other writer can slip between the two. Hold the series lock
+ from another connection and the publish must block rather than proceed on a
+ snapshot that is already stale.
+ """
+ thing_id, _ = published_well
+ parameter_id = _groundwater_level_parameter_id()
+ result = {}
+
+ def publish():
+ result["response"] = client.post(PUBLISH_URL, json=_payload(thing_id))
+
+ blocker = engine.connect()
+ try:
+ blocker.execute(
+ text("SELECT pg_advisory_xact_lock(:thing_id, :parameter_id)"),
+ {"thing_id": thing_id, "parameter_id": parameter_id},
+ )
+
+ worker = threading.Thread(target=publish, daemon=True)
+ worker.start()
+ worker.join(timeout=3)
+ assert worker.is_alive(), "publish did not wait for the series lock"
+
+ # Releasing the transaction releases the lock.
+ blocker.rollback()
+
+ worker.join(timeout=30)
+ assert not worker.is_alive(), "publish never completed after the lock lifted"
+ finally:
+ blocker.close()
+
+ assert result["response"].status_code == 201, result["response"].text
From 5b2cf6f3df416a33c86ca5c348aea1044b450237 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Wed, 19 Aug 2026 23:35:53 -0700
Subject: [PATCH 120/151] chore(transfers): deprecate NM_Aquifer/NM_Wells
drivers, drop tests from CI
Both legacy migration drivers are frozen. NM_Aquifer (transfers/transfer.py)
was already marked; bring the NM_Wells path to the same pattern with a
DEPRECATED: module docstring and a DeprecationWarning on each entry point:
transfer_geothermal.run_geothermal_transfer,
nmw_mirror_transfer.transfer_nmw_mirror, and export_nmw_csvs.main.
nmw_sql_dump is a pure parser, so it gets the docstring but no per-call
warning.
The code stays runnable rather than deleted because live API routes still read
the NMA_* and NMW_* tables, so backfills and re-runs must remain possible.
services/scoped_transfer.py imports ~25 of the NM_Aquifer transferers directly
to back the `oco scoped-transfer` command; it is left untouched and documented
as explicitly not deprecated.
Move the root-level transfer tests into tests/transfers/, which tests.yml
already excludes via --ignore=tests/transfers, so they no longer gate a pull
request. transfers/* was already omitted from the coverage total, so the gate
is unaffected. test_nmw_mirror.py derived ROOT by counting dirname levels from
__file__ and broke one directory deeper; it now resolves the repo root
explicitly.
Tests for the NMA_*/NMW_* ORM models stay in tests/ proper and still gate CI,
since live routes depend on those models.
CI scope collects 890 tests; the excluded transfer scope collects 87 and passes.
Co-Authored-By: Claude Opus 5
---
.github/workflows/tests.yml | 3 ++
CLAUDE.md | 26 ++++++++++++++++-
SPEC.md | 2 +-
docs/nm_wells-migration.md | 5 ++++
docs/nm_wells-transfer-runbook.md | 11 ++++++--
tests/README.md | 2 +-
tests/transfers/README.md | 24 ++++++++++++++++
.../test_contact_transfer_email_utils.py | 0
.../test_minor_trace_chemistry_transfer.py | 0
tests/{ => transfers}/test_nmw_mirror.py | 2 +-
tests/{ => transfers}/test_sensor_transfer.py | 0
tests/{ => transfers}/test_thing_transfer.py | 0
.../test_transfer_legacy_dates.py | 0
tests/{ => transfers}/test_well_transfer.py | 0
transfers/README.md | 28 ++++++++++++++++++-
transfers/export_nmw_csvs.py | 13 ++++++++-
transfers/nmw_mirror_transfer.py | 14 +++++++++-
transfers/nmw_sql_dump.py | 6 +++-
transfers/transfer_geothermal.py | 18 ++++++++++--
19 files changed, 141 insertions(+), 13 deletions(-)
create mode 100644 tests/transfers/README.md
rename tests/{unit => transfers}/test_contact_transfer_email_utils.py (100%)
rename tests/{ => transfers}/test_minor_trace_chemistry_transfer.py (100%)
rename tests/{ => transfers}/test_nmw_mirror.py (99%)
rename tests/{ => transfers}/test_sensor_transfer.py (100%)
rename tests/{ => transfers}/test_thing_transfer.py (100%)
rename tests/{ => transfers}/test_transfer_legacy_dates.py (100%)
rename tests/{ => transfers}/test_well_transfer.py (100%)
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index fb68c00e7..632b9feac 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -100,6 +100,9 @@ jobs:
- name: Run tests
# --cov-fail-under is set here rather than in pyproject so that running a
# single test file locally does not fail on the whole-project total.
+ # --ignore=tests/transfers excludes the deprecated NM_Aquifer / NM_Wells
+ # transfer tests; those scripts are frozen and run by hand against SQL
+ # Server, so they must not gate a pull request. See transfers/README.md.
run: uv run pytest -vv --durations=20 --cov --cov-report=xml --cov-report=html --cov-report=term-missing --cov-fail-under="$COVERAGE_FAIL_UNDER" --junitxml=junit.xml --ignore=tests/transfers
- name: Write coverage summary
diff --git a/CLAUDE.md b/CLAUDE.md
index 30549235f..147bab3d3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -77,9 +77,15 @@ POSTGRES_PASSWORD=
```
### Data Migration
+Both legacy transfer drivers are **deprecated** (see `transfers/README.md`); they
+raise `DeprecationWarning` and take no new migrations, but stay runnable for
+backfills.
```bash
-# Transfer data from legacy AMPAPI (NM_Aquifer) to new schema
+# NM_Aquifer (AMPAPI) -> new schema. Deprecated.
python -m transfers.transfer
+
+# NM_Wells (geothermal) Phase-1 staging mirror. Deprecated.
+python -m transfers.transfer_geothermal
```
## Architecture
@@ -285,6 +291,24 @@ GitHub Actions workflows (`.github/workflows/`):
## Legacy System Migration
+**Deprecated.** Both legacy drivers are frozen -- `transfers/transfer.py`
+(NM_Aquifer/AMPAPI) and `transfers/transfer_geothermal.py` (NM_Wells, with
+`nmw_mirror_transfer.py`, `nmw_sql_dump.py`, `export_nmw_csvs.py`). Entry points
+raise `DeprecationWarning`. Do not add new migrations to either. They remain
+runnable because live API routes still read the `NMA_*` and `NMW_*` tables.
+Read **`transfers/README.md`** before touching this layer.
+
+Their tests live in `tests/transfers/` and **do not gate CI** --
+`.github/workflows/tests.yml` runs pytest with `--ignore=tests/transfers`, and
+`transfers/*` is omitted from coverage in `pyproject.toml`. Run them by hand:
+`uv run pytest tests/transfers`. Tests for the `NMA_*`/`NMW_*` ORM models
+(`db/nma_legacy.py`, `db/nmw_legacy.py`) stay in `tests/` proper and still gate
+CI, since live routes depend on those models.
+
+Still live, *not* deprecated: `services/scoped_transfer.py` and the
+`oco scoped-transfer` command, which import the individual NM_Aquifer
+transferers directly.
+
**Source**: AMPAPI (SQL Server, `NM_Aquifer` schema)
**Target**: OcotilloAPI (PostgreSQL + PostGIS)
diff --git a/SPEC.md b/SPEC.md
index 3731fa02e..727c3ade7 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -61,7 +61,7 @@ T8|x|export_nmw_csvs.py pymssql export|I.export
T9|x|transfer_geothermal.py orchestrator|I.cli
T10|x|6 OGC collections in pygeoapi-config.yml|V6,I.ogc
T11|x|FK enforced via migration op.create_foreign_key; model index-only (resolved)|V2,V10
-T12|x|add NMW_* mirror/loader/migration/OGC tests (tests/test_nmw_mirror.py, 19 tests); found+fixed CAST-unwrap bug B1|V1,V2,V3,V5,V6,V10,V11
+T12|x|add NMW_* mirror/loader/migration/OGC tests (tests/transfers/test_nmw_mirror.py, 19 tests); found+fixed CAST-unwrap bug B1|V1,V2,V3,V5,V6,V10,V11
T13|.|verify alembic down path drops all views+tables (V3) on real db|V3
T14|.|run end-to-end load vs real dump, capture row counts per table|V2,V4
T15|.|finish PR #738 body (truncated at "- I ") + reviewer notes|-
diff --git a/docs/nm_wells-migration.md b/docs/nm_wells-migration.md
index 35f335b5d..16cd97c6b 100644
--- a/docs/nm_wells-migration.md
+++ b/docs/nm_wells-migration.md
@@ -1,5 +1,10 @@
# NM_Wells → Ocotillo migration
+> **Deprecated.** The Phase-1 loader described here is frozen and Phase 2 is not
+> being pursued through this path; see [transfers/README.md](../transfers/README.md).
+> Retained as the design record for the `NMW_*` mirror tables, which live API
+> routes still read.
+
Migration of the legacy **NM_Wells** SQL Server database (and the related
Subsurface Library) into OcotilloAPI. Source of truth for table inventory and
field-level recommendations: `NM_Wells + Subsurface library.xlsx` (planning
diff --git a/docs/nm_wells-transfer-runbook.md b/docs/nm_wells-transfer-runbook.md
index 60731a8b5..9a78fea33 100644
--- a/docs/nm_wells-transfer-runbook.md
+++ b/docs/nm_wells-transfer-runbook.md
@@ -1,5 +1,12 @@
# NM_Wells 1:1 Mirror Transfer — Runbook
+> **Deprecated.** The NM_Wells transfer path is frozen: no new migrations, no new
+> features, and its tests no longer gate CI (they live in `tests/transfers/`,
+> which `.github/workflows/tests.yml` ignores). The steps below stay accurate and
+> the code stays runnable, because the `NMW_*` tables are still read by live API
+> routes and so backfills and re-runs must remain possible. See
+> [transfers/README.md](../transfers/README.md).
+
Operational steps to run the NM_Wells (geothermal) Phase-1 mirror transfer and verify it
worked. Phase 1 is a faithful, column-for-column copy of the legacy NM_Wells SQL Server
tables into the Postgres `NMW_*` staging mirror — no transform to the Ocotillo model.
@@ -225,8 +232,8 @@ psql "$DATABASE_URL" -c '\dv ogc_*' # expect 0
alembic upgrade head # recreate
```
-Automated coverage for this lives in `tests/test_nmw_mirror.py` (19 tests):
-`uv run pytest tests/test_nmw_mirror.py`.
+Automated coverage for this lives in `tests/transfers/test_nmw_mirror.py` (19 tests):
+`uv run pytest tests/transfers/test_nmw_mirror.py`.
---
diff --git a/tests/README.md b/tests/README.md
index 2593c5930..402c4c6de 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -6,7 +6,7 @@ This directory contains automated tests (unit, integration, transfer, and API be
- `tests/unit/`: focused unit tests
- `tests/integration/`: cross-component tests
-- `tests/transfers/`: transfer-focused tests
+- `tests/transfers/`: transfer-focused tests for the deprecated `transfers/` scripts; excluded from CI (see `tests/transfers/README.md`)
- `tests/features/`: BDD-style feature tests
## Running tests
diff --git a/tests/transfers/README.md b/tests/transfers/README.md
new file mode 100644
index 000000000..e93578e83
--- /dev/null
+++ b/tests/transfers/README.md
@@ -0,0 +1,24 @@
+# Transfer tests
+
+Tests for the legacy migration scripts in `transfers/` — the deprecated
+NM_Aquifer (AMPAPI) and NM_Wells drivers.
+
+## Excluded from CI
+
+`.github/workflows/tests.yml` runs pytest with `--ignore=tests/transfers`, so
+nothing in this directory gates a pull request. The transfer scripts they cover
+are deprecated and run by hand against SQL Server; `transfers/*` is likewise
+omitted from the coverage total in `pyproject.toml`.
+
+Put new tests here only if they exercise `transfers/`. Tests for the
+`NMA_*`/`NMW_*` ORM models (`db/nma_legacy.py`, `db/nmw_legacy.py`) belong in
+`tests/` proper — those tables are still read by live API routes, so they stay
+in CI.
+
+## Running them
+
+From the repo root, against the `ocotilloapi_test` database:
+
+```bash
+uv run pytest tests/transfers
+```
diff --git a/tests/unit/test_contact_transfer_email_utils.py b/tests/transfers/test_contact_transfer_email_utils.py
similarity index 100%
rename from tests/unit/test_contact_transfer_email_utils.py
rename to tests/transfers/test_contact_transfer_email_utils.py
diff --git a/tests/test_minor_trace_chemistry_transfer.py b/tests/transfers/test_minor_trace_chemistry_transfer.py
similarity index 100%
rename from tests/test_minor_trace_chemistry_transfer.py
rename to tests/transfers/test_minor_trace_chemistry_transfer.py
diff --git a/tests/test_nmw_mirror.py b/tests/transfers/test_nmw_mirror.py
similarity index 99%
rename from tests/test_nmw_mirror.py
rename to tests/transfers/test_nmw_mirror.py
index 4de183fe4..30552f734 100644
--- a/tests/test_nmw_mirror.py
+++ b/tests/transfers/test_nmw_mirror.py
@@ -38,7 +38,7 @@
from transfers.nmw_mirror_transfer import NMW_MIRROR_SPECS
from transfers.nmw_sql_dump import _parse_value, iter_table_rows
-ROOT = os.path.dirname(os.path.dirname(__file__))
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# DB relations created by the OGC-view migrations (d1e2f3a4b5c6, e2f3a4b5c6d7).
OGC_VIEWS = [
diff --git a/tests/test_sensor_transfer.py b/tests/transfers/test_sensor_transfer.py
similarity index 100%
rename from tests/test_sensor_transfer.py
rename to tests/transfers/test_sensor_transfer.py
diff --git a/tests/test_thing_transfer.py b/tests/transfers/test_thing_transfer.py
similarity index 100%
rename from tests/test_thing_transfer.py
rename to tests/transfers/test_thing_transfer.py
diff --git a/tests/test_transfer_legacy_dates.py b/tests/transfers/test_transfer_legacy_dates.py
similarity index 100%
rename from tests/test_transfer_legacy_dates.py
rename to tests/transfers/test_transfer_legacy_dates.py
diff --git a/tests/test_well_transfer.py b/tests/transfers/test_well_transfer.py
similarity index 100%
rename from tests/test_well_transfer.py
rename to tests/transfers/test_well_transfer.py
diff --git a/transfers/README.md b/transfers/README.md
index 08e032349..2bac5b0b1 100644
--- a/transfers/README.md
+++ b/transfers/README.md
@@ -2,9 +2,35 @@
This directory contains legacy-to-target ETL transfer logic.
+## Status: deprecated
+
+Both legacy migration drivers are frozen. Do not add new migrations to either:
+
+- `transfers/transfer.py` -- the NM_Aquifer (AMPAPI, SQL Server) driver.
+- `transfers/transfer_geothermal.py` -- the NM_Wells (geothermal) driver, plus
+ its `nmw_mirror_transfer.py`, `nmw_sql_dump.py`, and `export_nmw_csvs.py`
+ supporting modules.
+
+Their top-level entry points raise `DeprecationWarning`. They are kept runnable
+because the tables they populate (`NMA_*`, `NMW_*`) are still read by live API
+routes, so backfills and re-runs must remain possible -- but they receive no new
+features.
+
+Consequently their tests live in `tests/transfers/` and do **not** gate CI
+(`.github/workflows/tests.yml` runs pytest with `--ignore=tests/transfers`), and
+`transfers/*` is omitted from the coverage total in `pyproject.toml`. Run them by
+hand with `uv run pytest tests/transfers`.
+
+Still live and *not* deprecated:
+
+- `services/scoped_transfer.py` and the `oco scoped-transfer` command, which
+ import the individual NM_Aquifer transferers directly.
+- `transfers/seed_geothermal.py`, a dev/test seeder that generates fake data
+ rather than reading a legacy source.
+
## Main orchestration
-- `transfers/transfer.py`
+- `transfers/transfer.py` (deprecated)
## Important supporting modules
diff --git a/transfers/export_nmw_csvs.py b/transfers/export_nmw_csvs.py
index 51daf403e..6ee6cc106 100644
--- a/transfers/export_nmw_csvs.py
+++ b/transfers/export_nmw_csvs.py
@@ -1,4 +1,8 @@
-"""Export NM_Wells SQL Server tables to CSV files for the transfer pipeline.
+"""DEPRECATED: export NM_Wells SQL Server tables to CSV for the transfer pipeline.
+
+Part of the frozen NM_Wells migration path; see the deprecation note in
+``transfers/transfer_geothermal.py``. Kept runnable for re-exports, but it gets
+no new features and its tests no longer gate CI.
Connects to the NM_Wells SQL Server database and exports each source table to
transfers/data/nma_csv_cache/.csv, which is where nmw_mirror_transfer.py
@@ -16,6 +20,7 @@
"""
import os
+import warnings
from pathlib import Path
import pymssql
@@ -64,6 +69,12 @@ def export_table(cursor, table: str, out_path: Path) -> int:
def main():
+ warnings.warn(
+ "transfers.export_nmw_csvs is deprecated; the NM_Wells migration path "
+ "is frozen and receives no new migrations.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
OUT_DIR.mkdir(parents=True, exist_ok=True)
print(
f"Connecting to {os.environ.get('NMW_HOST')} / {os.environ.get('NMW_DATABASE', 'NM_Wells')}"
diff --git a/transfers/nmw_mirror_transfer.py b/transfers/nmw_mirror_transfer.py
index d59ef4eaf..1d42fef6c 100644
--- a/transfers/nmw_mirror_transfer.py
+++ b/transfers/nmw_mirror_transfer.py
@@ -13,7 +13,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
-"""Load the NM_Wells SQL dump into the ``NMW_*`` 1:1 staging mirror tables.
+"""DEPRECATED: load the NM_Wells SQL dump into the ``NMW_*`` staging mirror.
+
+Part of the frozen NM_Wells migration path; see the deprecation note in
+``transfers/transfer_geothermal.py``. The ``NMW_*`` tables it populates are
+still read by live API routes, so this loader stays runnable for backfills and
+re-runs, but it gets no new features and its tests no longer gate CI.
Phase 1 of the NM_Wells migration (see db/nmw_legacy.py and
docs/nm_wells-migration.md). This is a faithful copy: each source table's CSV
@@ -47,6 +52,7 @@
import os
import tempfile
import uuid
+import warnings
from dataclasses import dataclass
import pandas as pd
@@ -317,6 +323,12 @@ def transfer_nmw_mirror(session: Session, limit: int = None) -> tuple:
``(session, limit)`` signature as the other session-based transfers. Returns
``(num_tables_loaded, total_rows_inserted, errors)``.
"""
+ warnings.warn(
+ "transfers.nmw_mirror_transfer is deprecated; the NM_Wells migration "
+ "path is frozen and receives no new migrations.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
limit = int(limit or 0)
dump = os.getenv(_SQL_DUMP_ENV)
out_dir = None
diff --git a/transfers/nmw_sql_dump.py b/transfers/nmw_sql_dump.py
index f7010b849..e0b758539 100644
--- a/transfers/nmw_sql_dump.py
+++ b/transfers/nmw_sql_dump.py
@@ -13,7 +13,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
-"""Parse a SQL Server data-dump ``.sql`` file into per-table CSVs.
+"""DEPRECATED: parse a SQL Server data-dump ``.sql`` file into per-table CSVs.
+
+Part of the frozen NM_Wells migration path; see the deprecation note in
+``transfers/transfer_geothermal.py``. Kept runnable for re-runs of the NM_Wells
+dump load, but it gets no new features and its tests no longer gate CI.
``INSERT [dbo].[] () VALUES ()[, () ...]`` statements
(SSMS "Generate Scripts -> data" / bcp INSERT mode) are split with ``sqlparse``
diff --git a/transfers/transfer_geothermal.py b/transfers/transfer_geothermal.py
index 6945ea01d..5071d32aa 100644
--- a/transfers/transfer_geothermal.py
+++ b/transfers/transfer_geothermal.py
@@ -13,10 +13,15 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
-"""Standalone orchestrator for the NM_Wells (geothermal) migration.
+"""DEPRECATED: standalone orchestrator for the NM_Wells (geothermal) migration.
-Separate from the deprecated ``transfers/transfer.py`` (NM_Aquifer driver). This
-script runs the NM_Wells Phase-1 staging migration:
+Deprecated alongside ``transfers/transfer.py`` (the NM_Aquifer driver): both
+legacy migration drivers are frozen. Do not add new migrations here. The
+``NMW_*`` staging tables this loads remain in service -- live API routes still
+read them -- so the loader is kept runnable for backfills and re-runs, but it
+gets no new features and its tests no longer gate CI.
+
+This script runs the NM_Wells Phase-1 staging migration:
1. Reference -> lexicon load (``ref_*`` lookups), gated by
``TRANSFER_GEOTHERMAL_REFERENCE`` (default True).
@@ -37,6 +42,7 @@
"""
import os
+import warnings
from dotenv import load_dotenv
@@ -63,6 +69,12 @@
def run_geothermal_transfer(limit: int = None) -> dict:
"""Run the NM_Wells geothermal staging migration. Returns a summary dict."""
+ warnings.warn(
+ "transfers.transfer_geothermal is deprecated; the NM_Wells migration "
+ "drivers are frozen and receive no new migrations.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
limit = int(limit if limit is not None else os.getenv("TRANSFER_LIMIT", 0) or 0)
summary: dict = {}
From 7a3915f41a7eaa3da7f6886e0a93d3caa7cbdfe0 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Thu, 20 Aug 2026 16:44:30 -0700
Subject: [PATCH 121/151] feat(transducer): backfill data_maturity on acoustic
observations
Revision b2c3d4e5f6a7 backfilled data_maturity from
nma_waterlevelscontinuous_pressure_qced. Acoustic readings have no such
flag -- AMPAPI's WaterLevelsContinuous_Acoustic table has no QCed column
-- so all 394,086 of them were skipped, which is the entire Wellntel
record (BDMS-1169).
Rows are matched on nma_waterlevelscontinuous_acoustic_global_id rather
than on a NULL pressure flag: the global id is written by the acoustic
transferer on every row and never by the pressure one, so it identifies
provenance instead of merely the absence of evidence.
Only rows whose data_maturity is still NULL are touched, so re-running is
a no-op and a maturity set deliberately since -- by the hydrograph
corrector, or by a later migration once the acoustic QC history is known
-- is left alone rather than reset to the blanket value.
The value itself is a recorded decision, not a derivation; there is no QC
field in the acoustic legacy schema to read. The transfer's
review_status='approved' blocks are not evidence for it, since those come
from PublicRelease, which describes visibility rather than review.
Co-Authored-By: Claude Opus 5
---
...20_0001_backfill_acoustic_data_maturity.py | 88 +++++++++++++++++
tests/test_data_migrations.py | 96 ++++++++++++++++++-
2 files changed, 183 insertions(+), 1 deletion(-)
create mode 100644 data_migrations/migrations/20260820_0001_backfill_acoustic_data_maturity.py
diff --git a/data_migrations/migrations/20260820_0001_backfill_acoustic_data_maturity.py b/data_migrations/migrations/20260820_0001_backfill_acoustic_data_maturity.py
new file mode 100644
index 000000000..7996e4b74
--- /dev/null
+++ b/data_migrations/migrations/20260820_0001_backfill_acoustic_data_maturity.py
@@ -0,0 +1,88 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""
+Set `data_maturity` on the acoustic (Wellntel) transducer observations that
+alembic revision `b2c3d4e5f6a7` left NULL.
+
+That revision backfilled maturity from `nma_waterlevelscontinuous_pressure_qced`,
+the AMPAPI flag recording whether a reading was quality controlled. Acoustic
+readings have no such flag -- AMPAPI's `WaterLevelsContinuous_Acoustic` table has
+no `QCed` column at all -- so all 394,086 of them were skipped, which is the
+entire acoustic record (BDMS-1169).
+
+`MATURITY` is a deliberate choice, not a derivation. There is no QC field in the
+acoustic legacy schema to read, so nothing here computes the answer; the value
+below is the one recorded for these readings, applied uniformly. The transfer's
+`review_status='approved'` blocks are *not* evidence for it -- those come from
+`PublicRelease`, which every acoustic source row carries and which describes
+visibility rather than review.
+
+Rows are matched on `nma_waterlevelscontinuous_acoustic_global_id`, the AMPAPI
+row identity. It is written by `WaterLevelsContinuousAcousticTransferer` on every
+acoustic row and never by the pressure transferer, so it is the provenance
+marker: 394,086 rows carry it, and they are exactly the rows with no
+`pressure_qced`.
+
+Only rows where `data_maturity` is already NULL are touched. Re-running is
+therefore a no-op, and a maturity set deliberately since -- by the hydrograph
+corrector, or by a later migration once the acoustic QC history is known -- is
+left alone rather than reset to the blanket value.
+"""
+
+from sqlalchemy import update
+from sqlalchemy.orm import Session
+
+from data_migrations.base import DataMigration
+from db.transducer import TransducerObservation
+
+MATURITY = "approved"
+
+
+def run(session: Session) -> None:
+ """Set the maturity on acoustic observations that have none."""
+ result = session.execute(
+ update(TransducerObservation)
+ .where(
+ TransducerObservation.nma_waterlevelscontinuous_acoustic_global_id.isnot(
+ None
+ ),
+ TransducerObservation.data_maturity.is_(None),
+ )
+ .values(data_maturity=MATURITY)
+ .execution_options(synchronize_session=False)
+ )
+ print(
+ f" set data_maturity={MATURITY!r} on {result.rowcount} acoustic observations"
+ )
+ return None
+
+
+MIGRATION = DataMigration(
+ id="20260820_0001_backfill_acoustic_data_maturity",
+ alembic_revision="b2c3d4e5f6a7",
+ name="Backfill data_maturity on acoustic (Wellntel) observations",
+ description=(
+ "Revision b2c3d4e5f6a7 backfilled data_maturity from the pressure QC "
+ "flag, which acoustic readings do not have, leaving the entire 394,086 "
+ f"row Wellntel record NULL (BDMS-1169). Sets it to {MATURITY!r}. Only "
+ "touches rows whose maturity is still NULL."
+ ),
+ run=run,
+ is_repeatable=False,
+)
+
+
+# ============= EOF =============================================
diff --git a/tests/test_data_migrations.py b/tests/test_data_migrations.py
index 8c11177d0..bf349711e 100644
--- a/tests/test_data_migrations.py
+++ b/tests/test_data_migrations.py
@@ -14,8 +14,9 @@
# limitations under the License.
# ===============================================================================
import importlib
+from datetime import datetime, timedelta, timezone
-from sqlalchemy import select
+from sqlalchemy import delete, select
move_notes = importlib.import_module(
"data_migrations.migrations.20260205_0001_move_nma_location_notes"
@@ -23,10 +24,15 @@
publish_project_areas = importlib.import_module(
"data_migrations.migrations.20260714_0001_publish_project_areas"
)
+backfill_acoustic_maturity = importlib.import_module(
+ "data_migrations.migrations.20260820_0001_backfill_acoustic_data_maturity"
+)
from db.location import Location
from db.notes import Notes
from db.group import Group
from db.engine import session_ctx
+from db.transducer import TransducerObservation
+from tests import get_parameter_id
def test_move_nma_location_notes_creates_notes_and_clears_field():
@@ -139,3 +145,91 @@ def test_publish_project_areas_marks_project_area_groups_public():
session.delete(draft_with_area)
session.delete(draft_without_area)
session.commit()
+
+
+def test_backfill_acoustic_data_maturity_only_touches_null_acoustic_rows(
+ sensor_to_water_well_thing_deployment,
+):
+ deployment_id = sensor_to_water_well_thing_deployment.id
+ parameter_id = get_parameter_id("groundwater level", "Field Parameter")
+ observed = datetime(2019, 7, 23, 12, 0, tzinfo=timezone.utc)
+
+ with session_ctx() as session:
+ # An acoustic row with no maturity -- the case this migration exists for.
+ acoustic = TransducerObservation(
+ parameter_id=parameter_id,
+ deployment_id=deployment_id,
+ observation_datetime=observed,
+ value=42.0,
+ nma_waterlevelscontinuous_acoustic_global_id="ACOUSTIC-NULL",
+ )
+ # An acoustic row whose maturity was already set deliberately. The
+ # blanket value must not overwrite a decision someone made.
+ acoustic_already_set = TransducerObservation(
+ parameter_id=parameter_id,
+ deployment_id=deployment_id,
+ observation_datetime=observed + timedelta(hours=1),
+ value=43.0,
+ nma_waterlevelscontinuous_acoustic_global_id="ACOUSTIC-SET",
+ data_maturity="provisional",
+ )
+ # A pressure row with no maturity. NULL here means the pressure QC flag
+ # was NULL, which is a different question -- leave it alone.
+ pressure = TransducerObservation(
+ parameter_id=parameter_id,
+ deployment_id=deployment_id,
+ observation_datetime=observed + timedelta(hours=2),
+ value=44.0,
+ nma_waterlevelscontinuous_pressure_global_id="PRESSURE-NULL",
+ )
+ session.add_all([acoustic, acoustic_already_set, pressure])
+ session.commit()
+ ids = (acoustic.id, acoustic_already_set.id, pressure.id)
+
+ try:
+ backfill_acoustic_maturity.run(session)
+
+ session.refresh(acoustic)
+ session.refresh(acoustic_already_set)
+ session.refresh(pressure)
+ assert acoustic.data_maturity == backfill_acoustic_maturity.MATURITY
+ assert acoustic_already_set.data_maturity == "provisional"
+ assert pressure.data_maturity is None
+ finally:
+ session.execute(
+ delete(TransducerObservation).where(TransducerObservation.id.in_(ids))
+ )
+ session.commit()
+
+
+def test_backfill_acoustic_data_maturity_is_idempotent(
+ sensor_to_water_well_thing_deployment,
+):
+ deployment_id = sensor_to_water_well_thing_deployment.id
+ parameter_id = get_parameter_id("groundwater level", "Field Parameter")
+
+ with session_ctx() as session:
+ observation = TransducerObservation(
+ parameter_id=parameter_id,
+ deployment_id=deployment_id,
+ observation_datetime=datetime(2020, 1, 1, tzinfo=timezone.utc),
+ value=45.0,
+ nma_waterlevelscontinuous_acoustic_global_id="ACOUSTIC-REPEAT",
+ )
+ session.add(observation)
+ session.commit()
+ observation_id = observation.id
+
+ try:
+ backfill_acoustic_maturity.run(session)
+ backfill_acoustic_maturity.run(session)
+
+ session.refresh(observation)
+ assert observation.data_maturity == backfill_acoustic_maturity.MATURITY
+ finally:
+ session.execute(
+ delete(TransducerObservation).where(
+ TransducerObservation.id == observation_id
+ )
+ )
+ session.commit()
From fb64f68300631b871dc12b569b1f75214802ce00 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Fri, 21 Aug 2026 08:40:12 -0700
Subject: [PATCH 122/151] fix(seed): load reference data even when migrations
seeded a term
The development-mode startup seed calls ensure_seed_prereqs, which loaded
core/lexicon.json only when lexicon_term was completely empty. Since
b2c3d4e5f6a7 (transducer data maturity) the migration inserts a lexicon
term of its own, so on a fresh database that emptiness check now sees one
row and skips the real lexicon entirely.
What followed: init_parameter tripped parameter_default_unit_fkey because
'ft' and 'dimensionless' were never inserted, then seed_all called
random.choice on an empty organization category and died with
"IndexError: Cannot choose from an empty sequence". FastAPI aborted
startup, so the frontend's Cypress job sat on a readiness probe for its
full 720s timeout and failed with exit 124.
Both initializers leave existing rows alone, so ensure_seed_prereqs now
just runs them. init_parameter skips names already stored rather than
letting every re-run trip the unique constraint, and a new
assert_lexicon_ready names the empty categories instead of failing deep
inside the seed with an opaque IndexError.
Verified against a fresh database: migrations alone leave exactly one
lexicon term, and the seed now loads all 1144 and completes.
---
core/initializers.py | 8 ++++
tests/test_seed_prereqs.py | 83 ++++++++++++++++++++++++++++++++++++++
transfers/seed.py | 54 +++++++++++++++++++++----
3 files changed, 137 insertions(+), 8 deletions(-)
create mode 100644 tests/test_seed_prereqs.py
diff --git a/core/initializers.py b/core/initializers.py
index ee0fecbe2..25420615d 100644
--- a/core/initializers.py
+++ b/core/initializers.py
@@ -45,7 +45,15 @@ def init_parameter(path: str = None) -> None:
default_parameter = json.load(f)
with session_ctx() as session:
+ # A parameter is identified by name and matrix, so skip the ones already
+ # stored instead of letting every re-run trip the unique constraint.
+ existing = set(
+ session.execute(select(Parameter.parameter_name, Parameter.matrix)).all()
+ )
+
for param in default_parameter:
+ if (param["parameter_name"], param["matrix"]) in existing:
+ continue
try:
parameter_obj = Parameter(
parameter_name=param["parameter_name"],
diff --git a/tests/test_seed_prereqs.py b/tests/test_seed_prereqs.py
new file mode 100644
index 000000000..51e8244cc
--- /dev/null
+++ b/tests/test_seed_prereqs.py
@@ -0,0 +1,83 @@
+"""Reference data has to load before the seed touches it.
+
+Migrations insert a handful of lexicon terms of their own, so "the table has
+rows" says nothing about whether core/lexicon.json was ever loaded.
+"""
+
+import contextlib
+
+import pytest
+from sqlalchemy import func, select
+
+from core.initializers import init_parameter
+from db.engine import session_ctx
+from db.parameter import Parameter
+from transfers import seed as seed_module
+from transfers.seed import (
+ REQUIRED_LEXICON_CATEGORIES,
+ assert_lexicon_ready,
+ ensure_seed_prereqs,
+ get_terms_by_category,
+)
+
+
+def test_ensure_seed_prereqs_loads_reference_data_even_when_tables_have_rows(
+ monkeypatch,
+):
+ calls = []
+ monkeypatch.setattr(
+ "core.initializers.init_lexicon", lambda: calls.append("lexicon")
+ )
+ monkeypatch.setattr(
+ "core.initializers.init_parameter", lambda: calls.append("parameter")
+ )
+
+ ensure_seed_prereqs()
+
+ assert calls == ["lexicon", "parameter"]
+
+
+def test_assert_lexicon_ready_names_the_empty_categories(monkeypatch):
+ empty = {"organization", "note_type"}
+
+ @contextlib.contextmanager
+ def fake_session_ctx():
+ yield object()
+
+ monkeypatch.setattr(seed_module, "session_ctx", fake_session_ctx)
+ monkeypatch.setattr(
+ seed_module,
+ "get_terms_by_category",
+ lambda _session, category: [] if category in empty else ["term"],
+ )
+
+ with pytest.raises(RuntimeError) as excinfo:
+ assert_lexicon_ready()
+
+ message = str(excinfo.value)
+ assert "organization" in message
+ assert "note_type" in message
+ assert "sample_method" not in message
+
+
+def test_required_categories_have_terms_after_reference_data_loads():
+ with session_ctx() as session:
+ empty = [
+ category
+ for category in REQUIRED_LEXICON_CATEGORIES
+ if not get_terms_by_category(session, category)
+ ]
+
+ assert empty == []
+
+
+def test_init_parameter_leaves_existing_parameters_alone():
+ with session_ctx() as session:
+ before = session.scalar(select(func.count()).select_from(Parameter))
+
+ init_parameter()
+
+ with session_ctx() as session:
+ after = session.scalar(select(func.count()).select_from(Parameter))
+
+ assert after == before
diff --git a/transfers/seed.py b/transfers/seed.py
index bbe2c1885..6dbd7cd47 100644
--- a/transfers/seed.py
+++ b/transfers/seed.py
@@ -54,18 +54,55 @@ def get_terms_by_category(s, category_name: str) -> list[LexiconTerm]:
)
+# Lexicon categories the seed below draws terms from. Every one of them has to
+# have at least one term or the seed cannot build a coherent row.
+REQUIRED_LEXICON_CATEGORIES = (
+ "organization",
+ "relation",
+ "analysis_method_type",
+ "sample_method",
+ "activity_type",
+ "sensor_type",
+ "email_type",
+ "phone_type",
+ "address_type",
+ "well_purpose",
+ "casing_material",
+ "monitoring_frequency",
+ "note_type",
+ "participant_role",
+)
+
+
def ensure_seed_prereqs() -> None:
- """Ensure that lexicon and parameter data exist before seeding."""
+ """Load the reference lexicon and parameters that the seed data depends on.
+
+ Both initializers leave existing rows alone, so this runs unconditionally.
+ It used to skip them whenever the tables held any rows at all, which broke
+ once migrations started inserting lexicon terms of their own: on a fresh
+ database those few rows made the real lexicon look already-loaded, and the
+ seed then failed on empty categories.
+ """
from core.initializers import init_lexicon, init_parameter
- with session_ctx() as s:
- has_lexicon = s.scalar(select(LexiconTerm.id).limit(1)) is not None
- has_parameter = s.scalar(select(Parameter.id).limit(1)) is not None
+ init_lexicon()
+ init_parameter()
- if not has_lexicon:
- init_lexicon()
- if not has_parameter:
- init_parameter()
+
+def assert_lexicon_ready() -> None:
+ """Fail with the empty categories named, rather than deep inside the seed."""
+ with session_ctx() as s:
+ empty = [
+ category
+ for category in REQUIRED_LEXICON_CATEGORIES
+ if not get_terms_by_category(s, category)
+ ]
+
+ if empty:
+ raise RuntimeError(
+ "Reference lexicon is incomplete; no terms for "
+ f"{', '.join(empty)}. Seeding cannot continue."
+ )
def contact_data_exists() -> bool:
@@ -79,6 +116,7 @@ def seed_all(n: int = 5, skip_if_exists: bool = False):
print("Contact data exists; skipping seeding.")
return
ensure_seed_prereqs()
+ assert_lexicon_ready()
new_mexico_bounds = [
(36.9, -106.6), # Taos area
(35.1, -106.6), # Albuquerque
From 97d7f9fbff533dd19c933148decf18c9acb3cf45 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Fri, 21 Aug 2026 11:50:25 -0700
Subject: [PATCH 123/151] feat(chemistry): serve legacy water chemistry over
REST
Adds GET /chemistry/results, one row per analyte, and registers the
chemistry router -- it existed with every route commented out, so the API
served no chemistry at all.
The water chemistry is in the legacy NMA tables, not in the refactored
`observation` table, which holds none of it. Rather than read those four
tables directly, this serves `ogc_water_chemistry`, the view d9e0f1a2b3c4
already built by unioning them for the OGC EDR mount. Same rows, one
definition of what a chemistry result is. Only the public view is served,
so an unreleased thing or a sample flagged PublicRelease = false is not
reachable here regardless of who asks.
Analytes are stored as legacy symbols (`As`, `SO4`, `pHf`) and are
translated to the lexicon's parameter names on the way out, because that
is what callers key on to match a result to a drinking water standard.
Doing it per caller means each one gets to be wrong separately.
Ambiguous symbols are deliberately left untranslated so nothing can act on
a guess. `NO3` maps to the as-NO3 name rather than the as-N one: the
nitrate MCL is 10 mg/L as N, about 45 mg/L as NO3, so collapsing the two
would flag wells that are nowhere near the limit. The legacy data records
`NO3(N)` separately and that is what carries the as-N name.
`start_time` is inclusive and `end_time` exclusive so a calendar year is
expressible without picking up New Year's Day of the next one, and paging
breaks ties on id so analytes sharing a timestamp cannot be served twice
or skipped.
Refs BDMS-1189
Co-Authored-By: Claude Opus 5
---
api/chemisty.py | 84 +++++++++++++
core/initializers.py | 2 +
db/chemistry_views.py | 77 ++++++++++++
schemas/chemistry.py | 61 +++++++++
services/legacy_chemistry.py | 180 +++++++++++++++++++++++++++
tests/test_legacy_chemistry_names.py | 74 +++++++++++
6 files changed, 478 insertions(+)
create mode 100644 db/chemistry_views.py
create mode 100644 schemas/chemistry.py
create mode 100644 services/legacy_chemistry.py
create mode 100644 tests/test_legacy_chemistry_names.py
diff --git a/api/chemisty.py b/api/chemisty.py
index 5519c3f02..1e96970b0 100644
--- a/api/chemisty.py
+++ b/api/chemisty.py
@@ -13,7 +13,17 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
+from datetime import datetime
+
from fastapi import APIRouter
+from fastapi_pagination.ext.sqlalchemy import paginate
+from sqlalchemy import asc, desc, select
+
+from api.pagination import CustomPage
+from core.dependencies import amp_viewer_dependency, session_dependency
+from db.chemistry_views import WaterChemistryResultsView
+from schemas.chemistry import WaterChemistryResultResponse
+from services.legacy_chemistry import canonical_parameter_name
# from services.validation.chemistry import validate_analyte
@@ -25,6 +35,80 @@
)
+# Only columns that mean something to a client of this endpoint. A whitelist
+# rather than getattr on the view: the latter would expose every column,
+# including the ones carrying release state, as a public sort key.
+_RESULT_SORT_COLUMNS = {
+ "observation_datetime": WaterChemistryResultsView.observation_datetime,
+ "parameter_name": WaterChemistryResultsView.parameter_name,
+ "value": WaterChemistryResultsView.value,
+ "id": WaterChemistryResultsView.id,
+}
+
+
+@router.get("/results", summary="Get water chemistry results", tags=["chemistry"])
+def get_water_chemistry_results(
+ session: session_dependency,
+ user: amp_viewer_dependency,
+ thing_id: int | None = None,
+ start_time: datetime | None = None,
+ end_time: datetime | None = None,
+ sort: str | None = None,
+ order: str | None = None,
+) -> CustomPage[WaterChemistryResultResponse]:
+ """
+ Retrieve water chemistry results, one row per analyte.
+
+ Reads the legacy NMA chemistry tables, which is where the water chemistry
+ actually is -- the refactored `observation` table holds none of it. Rows
+ come from the public view, so an unreleased thing or a sample flagged
+ `PublicRelease = false` is not served here regardless of who is asking.
+
+ `start_time` is inclusive and `end_time` exclusive, so a calendar year is
+ `start_time=YYYY-01-01&end_time=YYYY+1-01-01` with no risk of picking up a
+ result recorded at midnight on New Year's Day of the following year.
+
+ `sort` accepts `observation_datetime`, `parameter_name`, `value`, or `id`;
+ `order` accepts `asc` or `desc`. The default is newest first, so a client
+ that wants a well's most recent analysis can ask for size 1.
+ """
+ query = select(WaterChemistryResultsView)
+
+ if thing_id is not None:
+ query = query.where(WaterChemistryResultsView.thing_id == thing_id)
+
+ if start_time is not None:
+ query = query.where(
+ WaterChemistryResultsView.observation_datetime >= start_time
+ )
+
+ if end_time is not None:
+ query = query.where(WaterChemistryResultsView.observation_datetime < end_time)
+
+ sort_column = _RESULT_SORT_COLUMNS.get(
+ sort or "observation_datetime",
+ WaterChemistryResultsView.observation_datetime,
+ )
+ direction = asc if (order or "desc").lower() == "asc" else desc
+
+ # id is the tiebreaker so paging is stable: without it two analytes sharing
+ # a timestamp can swap pages between requests and be served twice or never.
+ query = query.order_by(direction(sort_column), WaterChemistryResultsView.id)
+
+ def transformer(rows):
+ # Analytes come out of the legacy tables as symbols; the response
+ # speaks the lexicon's names so a consumer can match a result to a
+ # drinking water standard without knowing the legacy vocabulary.
+ return [
+ WaterChemistryResultResponse.model_validate(row).model_copy(
+ update={"parameter_name": canonical_parameter_name(row.parameter_name)}
+ )
+ for row in rows
+ ]
+
+ return paginate(query=query, conn=session, transformer=transformer)
+
+
# @router.get(
# "/analysis_set",
# response_model=CustomPage[WaterChemistryAnalysisSetResponse],
diff --git a/core/initializers.py b/core/initializers.py
index 25420615d..01ef37230 100644
--- a/core/initializers.py
+++ b/core/initializers.py
@@ -225,8 +225,10 @@ def register_api_routes(app):
from api.feedback import router as feedback_router
from api.disclaimer import router as disclaimer_router
from api.geothermal import router as geothermal_router
+ from api.chemisty import router as chemistry_router
app.include_router(asset_router)
+ app.include_router(chemistry_router)
app.include_router(author_router)
app.include_router(contact_router)
app.include_router(disclaimer_router)
diff --git a/db/chemistry_views.py b/db/chemistry_views.py
new file mode 100644
index 000000000..925a75ee6
--- /dev/null
+++ b/db/chemistry_views.py
@@ -0,0 +1,77 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Read-only mappings over the legacy water-chemistry views.
+
+`ogc_water_chemistry` and `ogc_internal_water_chemistry` are materialized views
+built in d9e0f1a2b3c4 by unioning the four legacy NMA chemistry tables
+(NMA_MajorChemistry, NMA_MinorTraceChemistry, NMA_Radionuclides,
+NMA_FieldParameters) into one analyte-per-row shape. They were added for the OGC
+EDR mount; these mappings let the REST API serve the same rows, which is where
+the chemistry data actually lives -- the refactored `observation` table holds no
+water chemistry.
+
+Views only. Like db/ngwmn_views.py these use their own declarative base so
+Alembic never tries to autogenerate a table for them, and the underlying
+relations are refreshed by the migration that owns them, not from here.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, Integer, String
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
+
+
+class ChemistryViewBase(DeclarativeBase):
+ """Declarative base for chemistry view mappings, excluded from Alembic."""
+
+
+class _WaterChemistryResultColumns:
+ """Columns shared by the public and internal chemistry views.
+
+ `id` is a text key (``maj-1``, ``min-2``, ``rad-3``, ``fld-4``) rather than
+ an integer: a row's identity is which legacy table it came from plus that
+ table's own id, and the four id sequences overlap.
+ """
+
+ id: Mapped[str] = mapped_column("id", String, primary_key=True)
+ thing_id: Mapped[int] = mapped_column("thing_id", Integer)
+ station_name: Mapped[str | None] = mapped_column("station_name", String)
+ thing_type: Mapped[str | None] = mapped_column("thing_type", String)
+ sample_id: Mapped[int | None] = mapped_column("sample_id", Integer)
+ parameter_name: Mapped[str] = mapped_column("parameter_name", String)
+ value: Mapped[float | None] = mapped_column("value", Float)
+ unit: Mapped[str | None] = mapped_column("unit", String)
+ # Named `datetime` in the view; exposed under the name the observation
+ # endpoints already use so clients do not need a second field name.
+ observation_datetime: Mapped[datetime] = mapped_column("datetime", DateTime)
+ release_status: Mapped[str | None] = mapped_column("release_status", String)
+
+
+class WaterChemistryResultsView(_WaterChemistryResultColumns, ChemistryViewBase):
+ """Public chemistry analyses: released things, released samples."""
+
+ __tablename__ = "ogc_water_chemistry"
+
+
+class InternalWaterChemistryResultsView(
+ _WaterChemistryResultColumns, ChemistryViewBase
+):
+ """Every chemistry analysis, including unreleased things and samples."""
+
+ __tablename__ = "ogc_internal_water_chemistry"
+
+
+# ============= EOF =============================================
diff --git a/schemas/chemistry.py b/schemas/chemistry.py
new file mode 100644
index 000000000..22024210b
--- /dev/null
+++ b/schemas/chemistry.py
@@ -0,0 +1,61 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+from datetime import datetime, timezone
+
+from pydantic import BaseModel, ConfigDict, field_serializer, field_validator
+
+
+class WaterChemistryResultResponse(BaseModel):
+ """One legacy chemistry analyte result.
+
+ Not a `BaseResponseModel`: the row comes from a view over the legacy NMA
+ tables, so it has a text id rather than an integer one and carries no
+ `created_at` of its own.
+ """
+
+ id: str
+ thing_id: int
+ station_name: str | None = None
+ sample_id: int | None = None
+ parameter_name: str
+ value: float | None = None
+ unit: str | None = None
+ observation_datetime: datetime
+
+ model_config = ConfigDict(from_attributes=True)
+
+ @field_validator("observation_datetime")
+ @classmethod
+ def assume_utc(cls, value: datetime) -> datetime:
+ """Stamp naive legacy timestamps as UTC.
+
+ The legacy tables store collection and analysis dates without a zone --
+ they are calendar dates, not instants. Attaching UTC keeps them stable:
+ `astimezone` on a naive value would read it in the server's local zone,
+ which would move a sample collected Jan 01 into the previous year for
+ any server west of Greenwich, and a report for that year would then come
+ back empty.
+ """
+ if value.tzinfo is None:
+ return value.replace(tzinfo=timezone.utc)
+ return value.astimezone(timezone.utc)
+
+ @field_serializer("observation_datetime")
+ def serialize_observation_datetime(self, value: datetime) -> str:
+ return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+# ============= EOF =============================================
diff --git a/services/legacy_chemistry.py b/services/legacy_chemistry.py
new file mode 100644
index 000000000..fff635260
--- /dev/null
+++ b/services/legacy_chemistry.py
@@ -0,0 +1,180 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Legacy analyte symbols to the lexicon's parameter names.
+
+The legacy NMA chemistry tables record analytes as symbols (`As`, `SO4`,
+`pHf`). Every consumer that wants to say something about a result -- compare it
+to a drinking water standard, group it, print it for a well owner -- needs the
+name, because that is what the rest of the system keys on. Doing that mapping
+per consumer means each one gets to be wrong on its own; doing it here means a
+symbol resolves the same way everywhere.
+
+An unrecognized symbol passes through unchanged rather than being dropped: the
+result is still real and still worth printing, it just carries no name anything
+can look up.
+
+Deliberate omissions
+--------------------
+Ambiguous symbols are left unmapped so nothing downstream can act on a guess.
+A parameter with no recognized name is reported without a standards comparison,
+which is the safe outcome -- inventing a name is what would let a limit be
+applied to the wrong quantity:
+
+- ``NO3``/``NO2`` map to the as-NO3/as-NO2 names, not the as-N ones. The
+ nitrate MCL is 10 mg/L *as N*, which is about 45 mg/L as NO3; mapping the
+ wrong one flags every moderately nitrated well in the state. ``NO3(N)`` and
+ ``NO2(N)`` are the as-N measurements and do map.
+- ``CN6``, ``DO``, ``ORP``, ``C14_years``, ``CF``, ``CFC*``, ``GA``, ``GB``,
+ ``Ra226``, ``Sr90``: no unambiguous lexicon term, so no mapping.
+"""
+
+# Symbol -> lexicon `parameter_name` term. Keys are matched case-sensitively
+# first, then case-insensitively, since the legacy tables are inconsistent
+# about capitalizing symbols.
+LEGACY_ANALYTE_NAMES: dict[str, str] = {
+ # --- Major ions and whole-water measures ---
+ "Ca": "Calcium",
+ "Ca(total)": "Calcium, total, unfiltered",
+ "Mg": "Magnesium",
+ "Mg(total)": "Magnesium, total, unfiltered",
+ "Na": "Sodium",
+ "Na(total)": "Sodium, total, unfiltered",
+ "K": "Potassium",
+ "K(total)": "Potassium, total, unfiltered",
+ "HCO3": "Bicarbonate",
+ "CO3": "Carbonate",
+ "SO4": "Sulfate",
+ "Cl": "Chloride",
+ "F": "Fluoride",
+ "Br": "Bromide",
+ "TDS": "Total Dissolved Solids",
+ "HRD": "Hardness (CaCO3)",
+ "ALK": "Alkalinity, Total",
+ "IONBAL": "Ion Balance",
+ "TAn": "Total Anions",
+ "TCat": "Total Cations",
+ "PO4": "Phosphate",
+ "NO3": "Nitrate (as NO3)",
+ "NO3(N)": "Nitrate (as N)",
+ "NO2": "Nitrite (as NO2)",
+ "NO2(N)": "Nitrite (as N)",
+ "NH4": "Ammonium",
+ "H2S": "Hydrogen sulfide",
+ "DOC": "Dissolved organic carbon",
+ "TOC": "Total organic carbon",
+ "TKN": "Total Kjeldahl nitrogen",
+ "TN": "Total nitrogen",
+ "SiO2": "Silica",
+ "Si": "Silicon",
+ "Si(total)": "Silicon, total, unfiltered",
+ # --- Metals and trace elements ---
+ "Ag": "Silver",
+ "Ag(total)": "Silver, total, unfiltered",
+ "Al": "Aluminum",
+ "Al(total)": "Aluminum, total, unfiltered",
+ "As": "Arsenic",
+ "As(total)": "Arsenic, total, unfiltered",
+ "B": "Boron",
+ "B(total)": "Boron, total, unfiltered",
+ "Ba": "Barium",
+ "Ba(total)": "Barium, total, unfiltered",
+ "Be": "Beryllium",
+ "Be(total)": "Beryllium, total, unfiltered",
+ "Cd": "Cadmium",
+ "Cd(total)": "Cadmium, total, unfiltered",
+ "Co": "Cobalt",
+ "Co(total)": "Cobalt, total, unfiltered",
+ "Cr": "Chromium",
+ "Cr(total)": "Chromium, total, unfiltered",
+ "Cu": "Copper",
+ "Cu(total)": "Copper, total, unfiltered",
+ "Fe": "Iron",
+ "Fe(total)": "Iron, total, unfiltered",
+ "Hg": "Mercury",
+ "Hg(total)": "Mercury, total, unfiltered",
+ "Li": "Lithium",
+ "Li(total)": "Lithium, total, unfiltered",
+ "Mn": "Manganese",
+ "Mn(total)": "Manganese, total, unfiltered",
+ "Mo": "Molybdenum",
+ "Mo(total)": "Molybdenum, total, unfiltered",
+ "Ni": "Nickel",
+ "Ni(total)": "Nickel, total, unfiltered",
+ "Pb": "Lead",
+ "Pb(total)": "Lead, total, unfiltered",
+ "Sb": "Antimony",
+ "Sb(total)": "Antimony, total, unfiltered",
+ "Se": "Selenium",
+ "Se(total)": "Selenium, total, unfiltered",
+ "Sn": "Tin",
+ "Sn(total)": "Tin, total, unfiltered",
+ "Sr": "Strontium",
+ "Sr(total)": "Strontium, total, unfiltered",
+ "Th": "Thorium",
+ "Th(total)": "Thorium, total, unfiltered",
+ "Ti": "Titanium",
+ "Ti(total)": "Titanium, total, unfiltered",
+ "Tl": "Thallium",
+ "Tl(total)": "Thallium, total, unfiltered",
+ # The uranium MCL (0.03 mg/L) is for total uranium; the lexicon spells the
+ # measurement it belongs to with the method it is usually run by.
+ "U": "Uranium (total, by ICP-MS)",
+ "U(total)": "Uranium, total, unfiltered",
+ "V": "Vanadium",
+ "V(total)": "Vanadium, total, unfiltered",
+ "Zn": "Zinc",
+ "Zn(total)": "Zinc, total, unfiltered",
+ # --- Field and laboratory measurements ---
+ # Field and lab pH are the same quantity to the lexicon; which instrument
+ # read it is carried by the source table, not by the parameter name.
+ "pHf": "pH",
+ "pHL": "pH",
+ "T": "temperature",
+ "CONDLAB": "Conductivity, laboratory",
+ # --- Isotopes ---
+ "3H": "Tritium",
+ "H2r": "Deuterium:Hydrogen ratio",
+ "O18r": "18O:16O ratio",
+ "C13r": "13C:12C ratio",
+ "C14": "14C content, pmc",
+ "d18O-SO4": "delta O18 sulfate",
+ "d34S-SO4": "Sulfate 34 isotope ratio",
+}
+
+_LEGACY_ANALYTE_NAMES_LOWER = {
+ symbol.lower(): name for symbol, name in LEGACY_ANALYTE_NAMES.items()
+}
+
+
+def canonical_parameter_name(symbol: str | None) -> str | None:
+ """The lexicon parameter name for a legacy analyte symbol.
+
+ Returns the symbol unchanged when it is not one this module knows about.
+ """
+ if symbol is None:
+ return None
+
+ trimmed = symbol.strip()
+ if not trimmed:
+ return trimmed
+
+ if trimmed in LEGACY_ANALYTE_NAMES:
+ return LEGACY_ANALYTE_NAMES[trimmed]
+
+ return _LEGACY_ANALYTE_NAMES_LOWER.get(trimmed.lower(), trimmed)
+
+
+# ============= EOF =============================================
diff --git a/tests/test_legacy_chemistry_names.py b/tests/test_legacy_chemistry_names.py
new file mode 100644
index 000000000..6a83f3231
--- /dev/null
+++ b/tests/test_legacy_chemistry_names.py
@@ -0,0 +1,74 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Legacy analyte symbol to lexicon parameter name mapping."""
+
+import pytest
+
+from services.legacy_chemistry import canonical_parameter_name
+
+
+@pytest.mark.parametrize(
+ "symbol, expected",
+ [
+ ("As", "Arsenic"),
+ ("Pb", "Lead"),
+ ("SO4", "Sulfate"),
+ ("TDS", "Total Dissolved Solids"),
+ ("HRD", "Hardness (CaCO3)"),
+ ("pHf", "pH"),
+ ("pHL", "pH"),
+ ("As(total)", "Arsenic, total, unfiltered"),
+ ("H2r", "Deuterium:Hydrogen ratio"),
+ ],
+)
+def test_maps_legacy_symbols_to_lexicon_names(symbol, expected):
+ assert canonical_parameter_name(symbol) == expected
+
+
+def test_distinguishes_nitrate_as_n_from_nitrate_as_no3():
+ """The nitrate MCL is 10 mg/L *as N*, roughly 45 mg/L as NO3.
+
+ Collapsing the two would apply the as-N limit to an as-NO3 number and flag
+ wells that are nowhere near it, so only the as-N measurement gets the name
+ the standard is keyed to.
+ """
+ assert canonical_parameter_name("NO3(N)") == "Nitrate (as N)"
+ assert canonical_parameter_name("NO3") == "Nitrate (as NO3)"
+ assert canonical_parameter_name("NO2(N)") == "Nitrite (as N)"
+ assert canonical_parameter_name("NO2") == "Nitrite (as NO2)"
+
+
+@pytest.mark.parametrize("symbol", ["CN6", "DO", "ORP", "C14_years", "GA", "Ra226"])
+def test_leaves_ambiguous_symbols_alone(symbol):
+ """An unmapped symbol is reported as-is and compared to nothing.
+
+ Guessing a name is what would let a limit be applied to the wrong quantity.
+ """
+ assert canonical_parameter_name(symbol) == symbol
+
+
+def test_tolerates_legacy_capitalization_and_padding():
+ assert canonical_parameter_name(" as ") == "Arsenic"
+ assert canonical_parameter_name("TDS ") == "Total Dissolved Solids"
+
+
+def test_passes_through_unknown_and_empty_values():
+ assert canonical_parameter_name("NotAnAnalyte") == "NotAnAnalyte"
+ assert canonical_parameter_name("") == ""
+ assert canonical_parameter_name(None) is None
+
+
+# ============= EOF =============================================
From 67b522ca6bc26b8995a0e2d2d7e24b3dac8bbba8 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Fri, 21 Aug 2026 11:53:33 -0700
Subject: [PATCH 124/151] feat(chemistry): report which legacy table a result
came from
Adds `result_kind` (major, minor, radionuclide, field) to the chemistry
results response.
Whether a result was read at the wellhead or by a laboratory is a
distinction an owner-facing report has to draw, and the legacy tables are
the only record of it -- the refactored `parameter_type` it used to come
from is not populated for this data. The view keeps that provenance only
in its text id prefix, so the prefix is translated here into a field a
client can read instead of every client learning to parse an id.
Refs BDMS-1189
Co-Authored-By: Claude Opus 5
---
api/chemisty.py | 7 +++++--
schemas/chemistry.py | 8 ++++++++
services/legacy_chemistry.py | 24 ++++++++++++++++++++++++
tests/test_legacy_chemistry_names.py | 25 ++++++++++++++++++++++++-
4 files changed, 61 insertions(+), 3 deletions(-)
diff --git a/api/chemisty.py b/api/chemisty.py
index 1e96970b0..0fe0ed150 100644
--- a/api/chemisty.py
+++ b/api/chemisty.py
@@ -23,7 +23,7 @@
from core.dependencies import amp_viewer_dependency, session_dependency
from db.chemistry_views import WaterChemistryResultsView
from schemas.chemistry import WaterChemistryResultResponse
-from services.legacy_chemistry import canonical_parameter_name
+from services.legacy_chemistry import canonical_parameter_name, result_kind
# from services.validation.chemistry import validate_analyte
@@ -101,7 +101,10 @@ def transformer(rows):
# drinking water standard without knowing the legacy vocabulary.
return [
WaterChemistryResultResponse.model_validate(row).model_copy(
- update={"parameter_name": canonical_parameter_name(row.parameter_name)}
+ update={
+ "parameter_name": canonical_parameter_name(row.parameter_name),
+ "result_kind": result_kind(row.id),
+ }
)
for row in rows
]
diff --git a/schemas/chemistry.py b/schemas/chemistry.py
index 22024210b..10d94a0de 100644
--- a/schemas/chemistry.py
+++ b/schemas/chemistry.py
@@ -14,6 +14,7 @@
# limitations under the License.
# ===============================================================================
from datetime import datetime, timezone
+from typing import Literal
from pydantic import BaseModel, ConfigDict, field_serializer, field_validator
@@ -34,6 +35,13 @@ class WaterChemistryResultResponse(BaseModel):
value: float | None = None
unit: str | None = None
observation_datetime: datetime
+ # Which legacy table the result came from. A field measurement was read at
+ # the wellhead and a lab one was not, which is the distinction an
+ # owner-facing report has to draw -- and the legacy tables are the only
+ # place that distinction is recorded.
+ result_kind: Literal["major", "minor", "radionuclide", "field", "unknown"] = (
+ "unknown"
+ )
model_config = ConfigDict(from_attributes=True)
diff --git a/services/legacy_chemistry.py b/services/legacy_chemistry.py
index fff635260..a387a76b4 100644
--- a/services/legacy_chemistry.py
+++ b/services/legacy_chemistry.py
@@ -177,4 +177,28 @@ def canonical_parameter_name(symbol: str | None) -> str | None:
return _LEGACY_ANALYTE_NAMES_LOWER.get(trimmed.lower(), trimmed)
+# The view's text ids are prefixed with the legacy table they came from. That
+# prefix is the only record of whether a result was read in the field or by a
+# lab, so it is translated into something a client can read rather than being
+# left for each client to parse out of an id.
+_RESULT_KINDS = {
+ "maj": "major",
+ "min": "minor",
+ "rad": "radionuclide",
+ "fld": "field",
+}
+
+
+def result_kind(result_id: str | None) -> str:
+ """Which legacy chemistry table a view row came from."""
+ if not result_id:
+ return "unknown"
+
+ prefix, _, remainder = result_id.partition("-")
+ if not remainder:
+ return "unknown"
+
+ return _RESULT_KINDS.get(prefix, "unknown")
+
+
# ============= EOF =============================================
diff --git a/tests/test_legacy_chemistry_names.py b/tests/test_legacy_chemistry_names.py
index 6a83f3231..3715a811a 100644
--- a/tests/test_legacy_chemistry_names.py
+++ b/tests/test_legacy_chemistry_names.py
@@ -17,7 +17,7 @@
import pytest
-from services.legacy_chemistry import canonical_parameter_name
+from services.legacy_chemistry import canonical_parameter_name, result_kind
@pytest.mark.parametrize(
@@ -71,4 +71,27 @@ def test_passes_through_unknown_and_empty_values():
assert canonical_parameter_name(None) is None
+@pytest.mark.parametrize(
+ "result_id, expected",
+ [
+ ("maj-1", "major"),
+ ("min-19198", "minor"),
+ ("rad-7", "radionuclide"),
+ ("fld-42", "field"),
+ ],
+)
+def test_reads_the_source_table_off_the_id(result_id, expected):
+ """A field measurement was read at the wellhead and a lab one was not.
+
+ The view's id prefix is the only place that survives, so a client is told
+ which it is rather than being left to parse an id.
+ """
+ assert result_kind(result_id) == expected
+
+
+@pytest.mark.parametrize("result_id", ["", None, "1234", "unprefixed-", "zzz-1"])
+def test_unrecognized_ids_report_an_unknown_source(result_id):
+ assert result_kind(result_id) == "unknown"
+
+
# ============= EOF =============================================
From bff7faa91dbc14f5b3f8635082a1d32b9e3a77ed Mon Sep 17 00:00:00 2001
From: jakeross
Date: Fri, 21 Aug 2026 12:09:40 -0700
Subject: [PATCH 125/151] fix(thing): a well with no location no longer 500s
the listing
GET /thing/water-well returned 500 for any page containing a thing with no
current location: `current_location` was a required field, so the GeoJSON
validator was handed None, reached for `__table__` on it, and raised
AttributeError. One unlocated well made every well on its page unreadable.
The dev database has 49 of them, so the listing failed at any page size
that reached one.
A thing is associated with a location over an effective period, and that
period can be closed or never opened, so having no current location is a
state the schema has to be able to say. The field is now optional and the
validator hands None back for the annotation to resolve.
Found while pointing the chemistry report at this endpoint; the bug is
older than that and affects every consumer of the wells listing.
Co-Authored-By: Claude Opus 5
---
schemas/location.py | 8 ++++
schemas/thing.py | 2 +-
tests/test_thing_without_location.py | 61 ++++++++++++++++++++++++++++
3 files changed, 70 insertions(+), 1 deletion(-)
create mode 100644 tests/test_thing_without_location.py
diff --git a/schemas/location.py b/schemas/location.py
index e96a2474f..11139c84a 100644
--- a/schemas/location.py
+++ b/schemas/location.py
@@ -135,6 +135,14 @@ class LocationGeoJSONResponse(BaseModel):
@model_validator(mode="before")
@classmethod
def populate_fields(cls, data: Any) -> Any:
+ # A thing can have no current location -- it is associated with one
+ # over an effective period, and that period can be closed or never
+ # opened. Hand None straight back so the optional annotation resolves
+ # it, rather than reaching for __table__ on it and turning a well with
+ # no location into a 500 for the whole page it appears on.
+ if data is None:
+ return None
+
# convert row to dictionary
if not isinstance(data, dict):
data_dict = {c.name: getattr(data, c.name) for c in data.__table__.columns}
diff --git a/schemas/thing.py b/schemas/thing.py
index bb2b051eb..c2798b5fd 100644
--- a/schemas/thing.py
+++ b/schemas/thing.py
@@ -207,7 +207,7 @@ class BaseThingResponse(BaseResponseModel):
name: str
site_name: str | None = None
thing_type: str
- current_location: LocationGeoJSONResponse
+ current_location: LocationGeoJSONResponse | None = None
first_visit_date: PastOrTodayDate | None
groups: list[GroupResponse] = []
monitoring_status: str | None
diff --git a/tests/test_thing_without_location.py b/tests/test_thing_without_location.py
new file mode 100644
index 000000000..9031ad854
--- /dev/null
+++ b/tests/test_thing_without_location.py
@@ -0,0 +1,61 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""A thing with no current location must not take down the page it is on.
+
+A thing is associated with a location over an effective period, and that period
+can be closed or never opened. When `current_location` was a required field the
+GeoJSON validator was handed None, reached for `__table__` on it, and the whole
+listing came back 500 -- one unlocated well made every well unreadable.
+"""
+
+from db.engine import session_ctx
+from db.thing import Thing
+from main import app
+from schemas.location import LocationGeoJSONResponse
+from starlette.testclient import TestClient
+
+client = TestClient(app)
+
+
+def test_geojson_validator_passes_none_through():
+ assert LocationGeoJSONResponse.populate_fields(None) is None
+
+
+def test_listing_wells_survives_one_with_no_location(water_well_thing):
+ unlocated = Thing(
+ name="TEST-NOLOC-1",
+ thing_type="water well",
+ release_status="public",
+ )
+ with session_ctx() as session:
+ session.add(unlocated)
+ session.commit()
+ unlocated_id = unlocated.id
+
+ try:
+ response = client.get("/thing/water-well", params={"size": 100})
+ assert response.status_code == 200, response.text
+
+ items = {item["id"]: item for item in response.json()["items"]}
+ assert unlocated_id in items
+ assert items[unlocated_id]["current_location"] is None
+ finally:
+ with session_ctx() as session:
+ session.delete(session.get(Thing, unlocated_id))
+ session.commit()
+
+
+# ============= EOF =============================================
From 8e55d1d44d98df7e54df5becc32d29a4bab439c4 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Fri, 21 Aug 2026 12:13:35 -0700
Subject: [PATCH 126/151] feat(scripts): seed the test DB with real NMA legacy
chemistry
Working on the legacy chemistry REST routes means having legacy chemistry to
read, and until now the only way to get it into ocotilloapi_test was a SQL Server
connection or hand-written rows. Tests build their own single records inline,
which is right for unit tests but leaves nothing to exercise the normalized
chemistry views, the LIMS ingestion path, or a list endpoint against realistic
analyte, unit and detection-limit distributions.
This copies a bounded subset out of a local clone of another database (default
ocotillo_prod) into ocotilloapi_test, walking the dependency closure:
thing -> location + location_thing_association
-> NMA_Chemistry_SampleInfo
-> NMA_MajorChemistry
-> NMA_MinorTraceChemistry
The association rows matter as much as the locations. thing.nma_pk_location is a
legacy audit column, not the live link: the model reaches a location through
location_thing_association (Thing.location_associations), so seeding a thing
without association rows produces exactly the location-less well that bff7faa9
had to stop 500ing on.
Primary keys are deliberately not preserved. The target already holds unrelated
rows at low ids, so copying source ids verbatim would silently reparent sample
infos onto pre-existing test things wherever the ranges overlap. Rows are
inserted without an id and children are repointed at the new parent id.
Re-runs reconcile on the legacy natural keys rather than on ids: a sample info
whose nma_SamplePtID is already present is skipped, and location/thing are reused
via nma_pk_location/nma_pk_welldata. So a second run picks up the next unseeded
batch instead of duplicating the last one.
Candidates must have both a major and a minor/trace row, so every seeded sample
exercises both tables. thing.thing_type is a NOT NULL lexicon FK, so a thing
whose type the target lexicon lacks disqualifies its sample infos rather than
being patched; nullable lexicon-backed columns are nulled with a count reported.
Column sets are intersected per run, so schema drift between the two databases
degrades to a printed skip list instead of a crash. thing.search_vector is
skipped as trigger-maintained, and location.point round-trips as EWKT because
pg8000 carries no geometry codec.
The target name must contain 'test' unless --force is passed.
The seed is transient by design of the test suite: the session-scoped autouse
fixture in tests/conftest.py drops and re-migrates the schema, so any pytest run
wipes it and the script has to be re-run afterwards. That also means it cannot
perturb the suite -- pytest always starts from a clean schema. Documented in the
module docstring.
Verified by seeding 65 sample infos across two runs (764 major and 1223
minor/trace rows, 22 and 79 distinct analytes, 437 censored values): the second
run reported 60 already-seeded candidates skipped, natural keys stayed unique
(65/65 sample infos, 764/764 major GlobalIDs), no chemistry row was orphaned, all
60 chemistry things resolved a location through the association table, and every
copied location kept its geometry.
Depends on a local database clone, so it is a dev-box seeder and does not run in
CI.
Co-Authored-By: Claude Opus 5
---
scripts/seed_nma_chemistry.py | 550 ++++++++++++++++++++++++++++++++++
1 file changed, 550 insertions(+)
create mode 100644 scripts/seed_nma_chemistry.py
diff --git a/scripts/seed_nma_chemistry.py b/scripts/seed_nma_chemistry.py
new file mode 100644
index 000000000..6db364c68
--- /dev/null
+++ b/scripts/seed_nma_chemistry.py
@@ -0,0 +1,550 @@
+#!/usr/bin/env python3
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Seed the test database with NMA legacy major/minor chemistry data.
+
+Copies a small subset of real chemistry out of a local clone of another database
+(by default the ``ocotillo_prod`` clone) into ``ocotilloapi_test``, so that
+chemistry endpoints, the normalized-chemistry views and the LIMS ingestion code
+have realistic analytes, units, censored ("<") symbols and detection limits to
+read without a SQL Server connection.
+
+Copied per selected ``NMA_Chemistry_SampleInfo``:
+
+ thing (parent of the sample info; thing_id is NOT NULL)
+ -> location + location_thing_association (the live thing->location link)
+ -> NMA_Chemistry_SampleInfo
+ -> NMA_MajorChemistry rows
+ -> NMA_MinorTraceChemistry rows
+
+Primary keys are *not* preserved. The target already holds unrelated rows at low
+ids, so every row is inserted without its id and children are repointed at the
+new parent id. Legacy uuid/OBJECTID columns are copied verbatim -- they are the
+natural keys this script reconciles on, which is what makes re-runs idempotent:
+
+ NMA_Chemistry_SampleInfo."nma_SamplePtID" already present -> candidate skipped
+ location.nma_pk_location / thing.nma_pk_welldata already present -> reused
+
+Lexicon-backed columns are validated against the target ``lexicon_term`` table.
+Nullable ones are nulled out when the term is missing; ``thing.thing_type`` is
+NOT NULL, so a thing whose type is absent from the target lexicon disqualifies
+its sample infos instead.
+
+The seed is transient. ``tests/conftest.py`` has a session-scoped autouse
+fixture that drops and re-migrates the schema, so any ``pytest`` run wipes these
+rows -- re-run this script afterwards.
+
+Usage:
+ python -m scripts.seed_nma_chemistry # 60 sample infos
+ python -m scripts.seed_nma_chemistry --samples 200
+ python -m scripts.seed_nma_chemistry --dry-run
+"""
+
+from __future__ import annotations
+
+import argparse
+import getpass
+import os
+import sys
+from collections import defaultdict
+from typing import Any
+
+from dotenv import load_dotenv
+from sqlalchemy import create_engine, text
+from sqlalchemy.engine import Connection, Engine
+
+# Columns never copied: surrogate keys and the trigger-maintained search vector.
+SKIP_COLUMNS = {"id", "search_vector"}
+
+# Geometry columns are read as EWKT and re-parsed on insert; pg8000 has no
+# geometry codec of its own.
+GEOMETRY_COLUMNS = {("location", "point")}
+
+# Columns whose value must exist in the target lexicon_term table.
+LEXICON_COLUMNS = {
+ "location": {"release_status", "nma_data_reliability"},
+ "thing": {
+ "thing_type",
+ "release_status",
+ "formation_completion_code",
+ "spring_type",
+ "well_construction_method",
+ "well_pump_type",
+ },
+}
+
+CHEMISTRY_TABLES = ("NMA_MajorChemistry", "NMA_MinorTraceChemistry")
+
+
+def build_engine(database: str) -> Engine:
+ """Engine for `database` on the host configured in .env.
+
+ Deliberately does not import db.engine: that module binds one engine to
+ POSTGRES_DB at import time, and this script needs two databases at once.
+ """
+ password = os.environ.get("POSTGRES_PASSWORD", "")
+ host = os.environ.get("POSTGRES_HOST", "localhost")
+ port = os.environ.get("POSTGRES_PORT", "5432")
+ user = os.environ.get("POSTGRES_USER", "").strip() or getpass.getuser()
+
+ auth = f"{user}:{password}@" if user and password else ""
+ port_part = f":{port}" if port else ""
+ url = f"postgresql+pg8000://{auth}{host}{port_part}/{database}"
+ return create_engine(url, future=True)
+
+
+def copyable_columns(src: Connection, dst: Connection, table: str) -> list[str]:
+ """Columns present in both databases and safe to insert explicitly."""
+ sql = text(
+ "select column_name from information_schema.columns "
+ "where table_schema = 'public' and table_name = :t"
+ )
+ src_cols = {r[0] for r in src.execute(sql, {"t": table})}
+ dst_cols = {r[0] for r in dst.execute(sql, {"t": table})}
+ if not src_cols:
+ raise SystemExit(f"Source database has no table {table!r}")
+ if not dst_cols:
+ raise SystemExit(f"Target database has no table {table!r}")
+
+ missing = (src_cols - dst_cols) | (dst_cols - src_cols)
+ if missing:
+ print(f" {table}: skipping columns absent on one side: {sorted(missing)}")
+
+ return sorted((src_cols & dst_cols) - SKIP_COLUMNS)
+
+
+def select_clause(table: str, columns: list[str]) -> str:
+ parts = []
+ for col in columns:
+ if (table, col) in GEOMETRY_COLUMNS:
+ parts.append(f'ST_AsEWKT("{col}") as "{col}"')
+ else:
+ parts.append(f'"{col}"')
+ return ", ".join(parts)
+
+
+def insert_returning_id(
+ dst: Connection, table: str, columns: list[str], row: dict[str, Any]
+) -> int:
+ placeholders = []
+ for col in columns:
+ if (table, col) in GEOMETRY_COLUMNS:
+ placeholders.append(f"ST_GeomFromEWKT(:{col})")
+ else:
+ placeholders.append(f":{col}")
+
+ col_list = ", ".join(f'"{c}"' for c in columns)
+ sql = text(
+ f'insert into "{table}" ({col_list}) values ({", ".join(placeholders)}) '
+ "returning id"
+ )
+ return dst.execute(sql, {c: row[c] for c in columns}).scalar_one()
+
+
+def scrub_lexicon(
+ table: str, row: dict[str, Any], terms: set[str], nulled: dict[str, int]
+) -> None:
+ """Null out nullable lexicon-backed values the target lexicon lacks."""
+ for col in LEXICON_COLUMNS.get(table, ()):
+ if col == "thing_type": # NOT NULL; handled by candidate filtering
+ continue
+ value = row.get(col)
+ if value is not None and value not in terms:
+ row[col] = None
+ nulled[f"{table}.{col}"] += 1
+
+
+def load_lexicon_terms(dst: Connection) -> set[str]:
+ return {r[0] for r in dst.execute(text("select term from lexicon_term"))}
+
+
+def select_candidates(
+ src: Connection, dst: Connection, limit: int, terms: set[str]
+) -> list[dict[str, Any]]:
+ """Sample infos worth copying, oldest id first for a stable subset.
+
+ Requires both a major and a minor/trace row so the seed always exercises
+ both tables, and a thing whose type the target lexicon already knows.
+ """
+ seeded = {
+ r[0]
+ for r in dst.execute(
+ text(
+ 'select "nma_SamplePtID" from "NMA_Chemistry_SampleInfo" '
+ 'where "nma_SamplePtID" is not null'
+ )
+ )
+ }
+
+ rows = src.execute(
+ text(
+ """
+ select si.id, si."nma_SamplePtID", si.thing_id, t.thing_type
+ from "NMA_Chemistry_SampleInfo" si
+ join thing t on t.id = si.thing_id
+ where si."nma_SamplePtID" is not null
+ and exists (select 1 from "NMA_MajorChemistry" mc
+ where mc.chemistry_sample_info_id = si.id)
+ and exists (select 1 from "NMA_MinorTraceChemistry" mt
+ where mt.chemistry_sample_info_id = si.id)
+ order by si.id
+ """
+ )
+ ).mappings()
+
+ candidates = []
+ skipped_seeded = 0
+ skipped_type = 0
+ for row in rows:
+ if row["nma_SamplePtID"] in seeded:
+ skipped_seeded += 1
+ continue
+ if row["thing_type"] not in terms:
+ skipped_type += 1
+ continue
+ candidates.append(dict(row))
+ if len(candidates) >= limit:
+ break
+
+ if skipped_seeded:
+ print(f" {skipped_seeded} sample info(s) already seeded, skipped")
+ if skipped_type:
+ print(
+ f" {skipped_type} sample info(s) skipped: thing_type not in target lexicon"
+ )
+ return candidates
+
+
+def copy_location(
+ src: Connection,
+ dst: Connection,
+ columns: list[str],
+ source_location_id: int,
+ location_map: dict[int, int],
+ terms: set[str],
+ nulled: dict[str, int],
+) -> int | None:
+ """Copy one source location, reusing a target row when already present."""
+ if source_location_id in location_map:
+ return location_map[source_location_id]
+
+ row = (
+ src.execute(
+ text(
+ f"select {select_clause('location', columns)} from location "
+ "where id = :i"
+ ),
+ {"i": source_location_id},
+ )
+ .mappings()
+ .first()
+ )
+ if row is None:
+ return None
+ row = dict(row)
+
+ legacy_key = row.get("nma_pk_location")
+ if legacy_key is not None:
+ existing = dst.execute(
+ text("select id from location where nma_pk_location = :k limit 1"),
+ {"k": legacy_key},
+ ).scalar()
+ if existing is not None:
+ location_map[source_location_id] = existing
+ return existing
+
+ scrub_lexicon("location", row, terms, nulled)
+ target_id = insert_returning_id(dst, "location", columns, row)
+ location_map[source_location_id] = target_id
+ return target_id
+
+
+def copy_location_associations(
+ src: Connection,
+ dst: Connection,
+ column_sets: dict[str, list[str]],
+ source_thing_id: int,
+ target_thing_id: int,
+ location_map: dict[int, int],
+ terms: set[str],
+ nulled: dict[str, int],
+) -> tuple[int, int]:
+ """Copy a thing's locations and the association rows that link them.
+
+ thing.nma_pk_location is a legacy audit column; the live model reaches a
+ location through location_thing_association (Thing.location_associations),
+ so a seeded thing without association rows reads as a well with no location.
+ """
+ assoc_columns = column_sets["location_thing_association"]
+ rows = (
+ src.execute(
+ text(
+ f"select {select_clause('location_thing_association', assoc_columns)} "
+ "from location_thing_association where thing_id = :i order by id"
+ ),
+ {"i": source_thing_id},
+ )
+ .mappings()
+ .all()
+ )
+
+ locations = 0
+ associations = 0
+ for row in rows:
+ payload = dict(row)
+ source_location_id = payload["location_id"]
+ before = len(location_map)
+ target_location_id = copy_location(
+ src,
+ dst,
+ column_sets["location"],
+ source_location_id,
+ location_map,
+ terms,
+ nulled,
+ )
+ if target_location_id is None:
+ continue
+ if len(location_map) > before:
+ locations += 1
+
+ payload["location_id"] = target_location_id
+ payload["thing_id"] = target_thing_id
+ insert_returning_id(dst, "location_thing_association", assoc_columns, payload)
+ associations += 1
+
+ return locations, associations
+
+
+def copy_thing(
+ src: Connection,
+ dst: Connection,
+ columns: list[str],
+ thing_id: int,
+ terms: set[str],
+ nulled: dict[str, int],
+) -> tuple[int, bool]:
+ """Return (target thing id, created) for a source thing id."""
+ row = (
+ src.execute(
+ text(f"select {select_clause('thing', columns)} from thing where id = :i"),
+ {"i": thing_id},
+ )
+ .mappings()
+ .first()
+ )
+ if row is None:
+ raise SystemExit(f"Source thing {thing_id} vanished mid-run")
+ row = dict(row)
+
+ legacy_key = row.get("nma_pk_welldata")
+ if legacy_key is not None:
+ existing = dst.execute(
+ text("select id from thing where nma_pk_welldata = :k limit 1"),
+ {"k": legacy_key},
+ ).scalar()
+ if existing is not None:
+ return existing, False
+
+ scrub_lexicon("thing", row, terms, nulled)
+ return insert_returning_id(dst, "thing", columns, row), True
+
+
+def copy_chemistry(
+ src: Connection,
+ dst: Connection,
+ table: str,
+ columns: list[str],
+ source_sample_info_id: int,
+ target_sample_info_id: int,
+) -> int:
+ rows = (
+ src.execute(
+ text(
+ f'select {select_clause(table, columns)} from "{table}" '
+ "where chemistry_sample_info_id = :i order by id"
+ ),
+ {"i": source_sample_info_id},
+ )
+ .mappings()
+ .all()
+ )
+
+ count = 0
+ for row in rows:
+ payload = dict(row)
+ payload["chemistry_sample_info_id"] = target_sample_info_id
+ insert_returning_id(dst, table, columns, payload)
+ count += 1
+ return count
+
+
+def seed(source_db: str, target_db: str, samples: int, dry_run: bool) -> int:
+ source_engine = build_engine(source_db)
+ target_engine = build_engine(target_db)
+
+ nulled: dict[str, int] = defaultdict(int)
+ totals: dict[str, int] = defaultdict(int)
+
+ with source_engine.connect() as src, target_engine.begin() as dst:
+ print(f"Reading {source_db!r}, writing {target_db!r}")
+
+ column_sets = {
+ table: copyable_columns(src, dst, table)
+ for table in (
+ "location",
+ "thing",
+ "location_thing_association",
+ "NMA_Chemistry_SampleInfo",
+ *CHEMISTRY_TABLES,
+ )
+ }
+ terms = load_lexicon_terms(dst)
+
+ candidates = select_candidates(src, dst, samples, terms)
+ if not candidates:
+ print("Nothing to seed: no unseeded sample infos matched.")
+ return 0
+ print(f"Selected {len(candidates)} sample info(s) to copy")
+
+ if dry_run:
+ for candidate in candidates[:10]:
+ print(
+ f" would copy sample_info id={candidate['id']} "
+ f"thing_id={candidate['thing_id']}"
+ )
+ if len(candidates) > 10:
+ print(f" ... and {len(candidates) - 10} more")
+ dst.rollback()
+ return 0
+
+ thing_map: dict[int, int] = {}
+ location_map: dict[int, int] = {}
+ for candidate in candidates:
+ source_thing_id = candidate["thing_id"]
+ if source_thing_id not in thing_map:
+ target_thing_id, created = copy_thing(
+ src, dst, column_sets["thing"], source_thing_id, terms, nulled
+ )
+ thing_map[source_thing_id] = target_thing_id
+ if created:
+ totals["thing"] += 1
+
+ if created:
+ locations, associations = copy_location_associations(
+ src,
+ dst,
+ column_sets,
+ source_thing_id,
+ target_thing_id,
+ location_map,
+ terms,
+ nulled,
+ )
+ totals["location"] += locations
+ totals["location_thing_association"] += associations
+
+ info_columns = column_sets["NMA_Chemistry_SampleInfo"]
+ info_row = (
+ src.execute(
+ text(
+ f"select {select_clause('NMA_Chemistry_SampleInfo', info_columns)} "
+ 'from "NMA_Chemistry_SampleInfo" where id = :i'
+ ),
+ {"i": candidate["id"]},
+ )
+ .mappings()
+ .first()
+ )
+ payload = dict(info_row)
+ payload["thing_id"] = thing_map[source_thing_id]
+
+ target_info_id = insert_returning_id(
+ dst, "NMA_Chemistry_SampleInfo", info_columns, payload
+ )
+ totals["NMA_Chemistry_SampleInfo"] += 1
+
+ for table in CHEMISTRY_TABLES:
+ totals[table] += copy_chemistry(
+ src,
+ dst,
+ table,
+ column_sets[table],
+ candidate["id"],
+ target_info_id,
+ )
+
+ print("\nSeeded:")
+ for table in (
+ "location",
+ "thing",
+ "location_thing_association",
+ "NMA_Chemistry_SampleInfo",
+ *CHEMISTRY_TABLES,
+ ):
+ print(f" {table}: {totals[table]}")
+ if nulled:
+ print("\nNulled lexicon-backed values missing from the target lexicon:")
+ for key, count in sorted(nulled.items()):
+ print(f" {key}: {count}")
+ return 0
+
+
+def main() -> int:
+ load_dotenv(override=False)
+
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--source-db",
+ default="ocotillo_prod",
+ help="database to read from (default: ocotillo_prod)",
+ )
+ parser.add_argument(
+ "--target-db",
+ default="ocotilloapi_test",
+ help="database to write to (default: ocotilloapi_test)",
+ )
+ parser.add_argument(
+ "--samples",
+ type=int,
+ default=60,
+ help="number of sample infos to copy (default: 60)",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="report what would be copied, write nothing",
+ )
+ parser.add_argument(
+ "--force",
+ action="store_true",
+ help="allow a target database whose name lacks 'test'",
+ )
+ args = parser.parse_args()
+
+ if "test" not in args.target_db and not args.force:
+ parser.error(
+ f"refusing to write to {args.target_db!r}: name does not contain "
+ "'test'. Pass --force if this is really intended."
+ )
+ if args.source_db == args.target_db:
+ parser.error("--source-db and --target-db must differ")
+
+ return seed(args.source_db, args.target_db, args.samples, args.dry_run)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
From 51bb35bfc6037152ca2050bd284e47043e6fc179 Mon Sep 17 00:00:00 2001
From: jirhiker <2035568+jirhiker@users.noreply.github.com>
Date: Fri, 21 Aug 2026 19:17:38 +0000
Subject: [PATCH 127/151] Formatting changes
---
scripts/seed_nma_chemistry.py | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/scripts/seed_nma_chemistry.py b/scripts/seed_nma_chemistry.py
index 6db364c68..79ea18270 100644
--- a/scripts/seed_nma_chemistry.py
+++ b/scripts/seed_nma_chemistry.py
@@ -189,9 +189,7 @@ def select_candidates(
)
}
- rows = src.execute(
- text(
- """
+ rows = src.execute(text("""
select si.id, si."nma_SamplePtID", si.thing_id, t.thing_type
from "NMA_Chemistry_SampleInfo" si
join thing t on t.id = si.thing_id
@@ -201,9 +199,7 @@ def select_candidates(
and exists (select 1 from "NMA_MinorTraceChemistry" mt
where mt.chemistry_sample_info_id = si.id)
order by si.id
- """
- )
- ).mappings()
+ """)).mappings()
candidates = []
skipped_seeded = 0
From 4c3ba71634b467c3aecee44b43efa0a94d95d484 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sat, 22 Aug 2026 07:52:13 -0700
Subject: [PATCH 128/151] chore(ogc): hide four layers from the public catalog
BDMS-977 (A16), BDMS-978 (A17) and BDMS-979 (A18) all take a layer off
the public /ogcapi catalog without touching its data:
* avg_tds_wells averages 1.9 observations per well, so the statistic is
unreliable for half the catalog, and latest_depth_to_water_wells
repeats what water_well_summary already publishes.
* locations exposes 11,174 features, 11,173 of which duplicate coverage
the 11 thing-type layers already give, at the cost of being one of the
two largest layers served.
* other_things is internal vocabulary: "Thing" is the data-model name
for a monitoring point and "other" names no feature class. Five
features.
The first three are config entries, so they come out of
core/pygeoapi-config.yml and stay in pygeoapi-config-internal.yml.
other_things is generated from THING_COLLECTIONS, which feeds both
mounts, so it gets an internal_only marker that _thing_collections_block
honors and only the internal _write_config call opts into.
All backing relations are retained. /ogcapi-internal still publishes all
four to staff GIS clients, which is the internal use A18 asked us to
check for before dropping ogc_other_things, so no migration here.
The A1 and A13 scenario tables lose the rows a public client can no
longer request; the SQL-level A1 scenarios still cover those views'
release_status filter. A16/A17/A18 scenarios get step definitions and
the @production tag so CI enforces them.
Co-Authored-By: Claude Opus 5
---
README.md | 11 ++--
core/pygeoapi-config.yml | 69 --------------------
core/pygeoapi.py | 11 ++++
tests/features/ogc-cleanup-sprint1.feature | 54 +++++++---------
tests/features/steps/ogc-cleanup-sprint1.py | 72 +++++++++++++++++++++
tests/test_ogc.py | 24 +++----
tests/test_pygeoapi_mount.py | 25 +++++++
7 files changed, 150 insertions(+), 116 deletions(-)
diff --git a/README.md b/README.md
index 47a178e95..5b5df58b8 100644
--- a/README.md
+++ b/README.md
@@ -39,22 +39,21 @@ hits to `/_ah/warmup`.
curl http://localhost:8000/ogcapi
curl http://localhost:8000/ogcapi/conformance
curl http://localhost:8000/ogcapi/collections
-curl http://localhost:8000/ogcapi/collections/locations
+curl http://localhost:8000/ogcapi/collections/water_wells
```
### Items (GeoJSON)
```bash
-curl "http://localhost:8000/ogcapi/collections/locations/items?limit=10&offset=0"
-curl "http://localhost:8000/ogcapi/collections/water_wells/items?limit=5"
+curl "http://localhost:8000/ogcapi/collections/water_wells/items?limit=10&offset=0"
curl "http://localhost:8000/ogcapi/collections/springs/items?limit=5"
-curl "http://localhost:8000/ogcapi/collections/locations/items/123"
+curl "http://localhost:8000/ogcapi/collections/water_wells/items/123"
```
### BBOX + datetime filters
```bash
-curl "http://localhost:8000/ogcapi/collections/locations/items?bbox=-107.9,33.8,-107.8,33.9"
+curl "http://localhost:8000/ogcapi/collections/water_wells/items?bbox=-107.9,33.8,-107.8,33.9"
curl "http://localhost:8000/ogcapi/collections/water_wells/items?datetime=2020-01-01/2024-01-01"
```
@@ -63,7 +62,7 @@ curl "http://localhost:8000/ogcapi/collections/water_wells/items?datetime=2020-0
Use `filter` + `filter-lang=cql2-text` with `WITHIN(...)`:
```bash
-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"
+curl "http://localhost:8000/ogcapi/collections/water_wells/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"
```
### OpenAPI UI
diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml
index fb6fc6c2c..c82fcea2a 100644
--- a/core/pygeoapi-config.yml
+++ b/core/pygeoapi-config.yml
@@ -49,75 +49,6 @@ metadata:
# publishes "pointOfContact" as the service's hours of operation.
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: ogc_locations
- geom_field: point
-
- latest_depth_to_water_wells:
- type: collection
- title: Latest Depth to Water (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
-
latest_tds_wells:
type: collection
title: Latest TDS (Water Wells)
diff --git a/core/pygeoapi.py b/core/pygeoapi.py
index 017af4588..849f4f381 100644
--- a/core/pygeoapi.py
+++ b/core/pygeoapi.py
@@ -74,6 +74,11 @@
"Feature records that do not match another defined thing type."
),
"keywords": ["other"],
+ # "Thing" is internal data-model vocabulary and "other" names no
+ # recognisable feature class, so this layer is not published on the
+ # public mount (BDMS-979). Staff GIS clients still reach it through
+ # /ogcapi-internal, and ogc_other_things is retained either way.
+ "internal_only": True,
},
{
"id": "outfalls_wastewater_return_flow",
@@ -247,9 +252,12 @@ def _thing_collections_block(
user: str,
password_placeholder: str,
table_prefix: str = "ogc_",
+ include_internal_only: bool = False,
) -> str:
resources: dict[str, dict] = {}
for collection in THING_COLLECTIONS:
+ if collection.get("internal_only") and not include_internal_only:
+ continue
resources[collection["id"]] = {
"type": "collection",
"title": collection["title"],
@@ -384,6 +392,7 @@ def _write_config(
table_prefix: str = "ogc_",
template_path: Path | None = None,
include_edr: bool = False,
+ include_internal_only: bool = False,
) -> None:
host, port, dbname, user, password_placeholder = _pygeoapi_db_settings()
template = (template_path or _template_path()).read_text(encoding="utf-8")
@@ -394,6 +403,7 @@ def _write_config(
user=user,
password_placeholder=password_placeholder,
table_prefix=table_prefix,
+ include_internal_only=include_internal_only,
)
if include_edr:
# EDR collections (core/edr_provider.py), backed by
@@ -564,6 +574,7 @@ def mount_pygeoapi_internal(app: FastAPI) -> None:
table_prefix="ogc_internal_",
template_path=_internal_template_path(),
include_edr=True,
+ include_internal_only=True,
)
_generate_openapi(config_path, openapi_path)
_assert_server_settings_match(_pygeoapi_dir() / "pygeoapi-config.yml", config_path)
diff --git a/tests/features/ogc-cleanup-sprint1.feature b/tests/features/ogc-cleanup-sprint1.feature
index 6e3c1e71b..d747c8bef 100644
--- a/tests/features/ogc-cleanup-sprint1.feature
+++ b/tests/features/ogc-cleanup-sprint1.feature
@@ -51,7 +51,6 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
| meteorological_stations |
| diversions_surface_water |
| lakes_ponds_reservoirs |
- | other_things |
| water_well_summary |
| depth_to_water_trend_wells |
| water_elevation_wells |
@@ -59,15 +58,14 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
| minor_chemistry_wells |
| latest_tds_wells |
| actively_monitored_wells |
- | avg_tds_wells |
- | latest_depth_to_water_wells |
- | locations |
| project_areas |
Then each response contains only records where release_status is "public"
And no response contains a record where release_status is "private"
And no response contains a record where release_status is "draft"
- # other_things above: A1 must apply the filter to its view, but A18 removes
- # other_things from the catalog — run this scenario before A18 is applied
+ # other_things, avg_tds_wells, latest_depth_to_water_wells and locations
+ # are not listed above: A16/A17/A18 took them off the public catalog, so a
+ # public client can no longer request their items. A1's filter still
+ # applies to their views, which the SQL-level scenarios above cover.
@backend @ogc-exposure @sprint-1 @high-priority @A1 @production
Scenario: project_areas returns 56 rows after all records are updated to public
@@ -237,10 +235,9 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
| lakes_ponds_reservoirs |
| soil_gas_sample_locations |
| outfalls_wastewater_return_flow |
- | other_things |
Then each feature includes a last_observation_date property
- # other_things above: included in Group A view template, but A18 removes it
- # from the catalog — run this scenario before A18 is applied
+ # other_things is not listed: it is in the Group A view template, but A18
+ # took it off the public catalog — it is only reachable on /ogcapi-internal.
@backend @ogc-data-currency @sprint-1 @medium-priority @A13
Scenario: last_observation_date is NULL for things with no associated observations
@@ -256,11 +253,10 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
| lakes_ponds_reservoirs |
| soil_gas_sample_locations |
| outfalls_wastewater_return_flow |
- | other_things |
When a client requests those features
Then each feature's last_observation_date property is null
- # other_things above: included in Group A view template, but A18 removes it
- # from the catalog — run this scenario before A18 is applied
+ # other_things is not listed: it is in the Group A view template, but A18
+ # took it off the public catalog — it is only reachable on /ogcapi-internal.
@backend @ogc-data-currency @sprint-1 @medium-priority @A13
Scenario: Consumers can filter Group A layers by last_observation_date
@@ -276,30 +272,29 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
| lakes_ponds_reservoirs |
| soil_gas_sample_locations |
| outfalls_wastewater_return_flow |
- | other_things |
When a client requests items from each of those layers with filter
"""
last_observation_date > '2021-01-01'
"""
Then only features with a last_observation_date of "2023-06-01" are returned from each layer
- # other_things above: included in Group A view template, but A18 removes it
- # from the catalog — run this scenario before A18 is applied
+ # other_things is not listed: it is in the Group A view template, but A18
+ # took it off the public catalog — it is only reachable on /ogcapi-internal.
# ---------------------------------------------------------------------------
# A16 — Hide avg_tds_wells and latest_depth_to_water_wells from public catalog
# ---------------------------------------------------------------------------
- @backend @ogc-data-currency @sprint-1 @medium-priority @A16
+ @backend @ogc-data-currency @sprint-1 @medium-priority @A16 @production
Scenario: avg_tds_wells is absent from the public collections catalog
When a client requests /ogcapi/collections
Then the response does not include a collection with id avg_tds_wells
- @backend @ogc-data-currency @sprint-1 @medium-priority @A16
+ @backend @ogc-data-currency @sprint-1 @medium-priority @A16 @production
Scenario: latest_depth_to_water_wells is absent from the public collections catalog
When a client requests /ogcapi/collections
Then the response does not include a collection with id latest_depth_to_water_wells
- @backend @ogc-data-currency @sprint-1 @medium-priority @A16
+ @backend @ogc-data-currency @sprint-1 @medium-priority @A16 @production
Scenario: Backing matviews for hidden layers are retained in the database
Given avg_tds_wells and latest_depth_to_water_wells have been removed from the service catalog
When the database schema is inspected
@@ -310,12 +305,12 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
# A17 — Hide locations layer from the public catalog
# ---------------------------------------------------------------------------
- @backend @ogc-data-currency @sprint-1 @medium-priority @A17
+ @backend @ogc-data-currency @sprint-1 @medium-priority @A17 @production
Scenario: locations is absent from the public collections catalog
When a client requests /ogcapi/collections
Then the response does not include a collection with id locations
- @backend @ogc-data-currency @sprint-1 @medium-priority @A17
+ @backend @ogc-data-currency @sprint-1 @medium-priority @A17 @production
Scenario: Underlying locations table is retained in the database after catalog removal
Given the locations entry has been removed from the service configuration
When the database schema is inspected
@@ -325,22 +320,21 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
# A18 — Remove other_things from the public catalog
# ---------------------------------------------------------------------------
- @backend @ogc-naming @sprint-1 @medium-priority @A18
+ @backend @ogc-naming @sprint-1 @medium-priority @A18 @production
Scenario: other_things is absent from the public collections catalog
When a client requests /ogcapi/collections
Then the response does not include a collection with id other_things
- @backend @ogc-naming @sprint-1 @medium-priority @A18
- Scenario: other_things backing view is dropped when no internal usage exists
- Given the other_things view has zero references in the application codebase
- When the cleanup is applied
- Then the other_things backing view does not exist in the database schema
-
- @backend @ogc-naming @sprint-1 @medium-priority @A18
- Scenario: other_things backing view is retained when internal usage exists
+ # The A18 review found internal usage: /ogcapi-internal still publishes the
+ # layer to staff GIS clients off ogc_internal_other_things, and the public
+ # ogc_other_things view is still built by the shared Group A view template.
+ # Both views are therefore retained.
+ @backend @ogc-naming @sprint-1 @medium-priority @A18 @production
+ Scenario: other_things backing views are retained because the internal mount uses them
Given the other_things view has at least one reference in the application codebase
- When the cleanup is applied
+ When the database schema is inspected
Then the other_things backing view still exists in the database schema
+ And the internal other_things backing view still exists in the database schema
# ---------------------------------------------------------------------------
# A22 — Verify NULL measuring_point_height assumption for water level layers
diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py
index 6d701dedd..8497cca13 100644
--- a/tests/features/steps/ogc-cleanup-sprint1.py
+++ b/tests/features/steps/ogc-cleanup-sprint1.py
@@ -857,6 +857,78 @@ def step_then_no_collection_id_prefixed(context, prefix):
assert not offending, f"found collections with id prefixed {prefix!r}: {offending}"
+# ---------------------------------------------------------------------------
+# A16/A17/A18 -- Layers hidden from the public catalog, backing relations kept
+# ---------------------------------------------------------------------------
+
+
+@given(
+ "avg_tds_wells and latest_depth_to_water_wells have been removed from the "
+ "service catalog"
+)
+@given("the locations entry has been removed from the service configuration")
+@given("the other_things view has at least one reference in the application codebase")
+def step_given_layer_hidden_from_public_catalog(context):
+ # No-op marker: the catalog is core/pygeoapi-config.yml and
+ # core.pygeoapi.THING_COLLECTIONS, both artifacts under test rather than
+ # runtime state to arrange. Same treatment as the A1/A2/A11 givens.
+ pass
+
+
+@then("the response does not include a collection with id {collection_id}")
+def step_then_response_excludes_collection_id(context, collection_id):
+ payload = context.response.json()
+ ids = {collection["id"] for collection in payload["collections"]}
+ assert collection_id not in ids, (
+ f"{collection_id} is still published on the public catalog; "
+ f"collections: {sorted(ids)}"
+ )
+
+
+@then("the materialized view for {layer_id} exists in the database schema")
+def step_then_matview_for_layer_exists(context, layer_id):
+ relation = f"ogc_{layer_id}"
+ assert relation in context.schema_relations, (
+ f"{relation} is missing -- A16 hides the layer from the catalog but "
+ "keeps its materialized view for internal use"
+ )
+
+
+@then("the locations table still exists")
+def step_then_locations_table_still_exists(context):
+ # context.schema_relations covers views and materialized views only, so
+ # the base table needs its own lookup.
+ with session_ctx() as session:
+ relkind = session.execute(
+ text(
+ "SELECT c.relkind FROM pg_class c "
+ "JOIN pg_namespace n ON n.oid = c.relnamespace "
+ "WHERE n.nspname = 'public' AND c.relname = 'location'"
+ )
+ ).scalar_one_or_none()
+ assert relkind == "r", (
+ "the location table is missing -- A17 hides the layer from the "
+ f"catalog but keeps the underlying table (relkind={relkind!r})"
+ )
+
+
+def _assert_other_things_view_exists(context, relation):
+ assert relation in context.schema_relations, (
+ f"{relation} is missing -- A18 hides other_things from the public "
+ "catalog but /ogcapi-internal still serves the layer"
+ )
+
+
+@then("the other_things backing view still exists in the database schema")
+def step_then_other_things_view_still_exists(context):
+ _assert_other_things_view_exists(context, "ogc_other_things")
+
+
+@then("the internal other_things backing view still exists in the database schema")
+def step_then_internal_other_things_view_still_exists(context):
+ _assert_other_things_view_exists(context, "ogc_internal_other_things")
+
+
# ---------------------------------------------------------------------------
# A2 -- Replace OGC server metadata placeholders in pygeoapi-config.yml
# ---------------------------------------------------------------------------
diff --git a/tests/test_ogc.py b/tests/test_ogc.py
index 5385d243d..8b49ac0a6 100644
--- a/tests/test_ogc.py
+++ b/tests/test_ogc.py
@@ -565,7 +565,6 @@ def test_ogc_collections(ogc_client):
payload = response.json()
ids = {collection["id"] for collection in payload["collections"]}
assert {
- "locations",
"water_wells",
"springs",
"latest_tds_wells",
@@ -577,6 +576,19 @@ def test_ogc_collections(ogc_client):
"actively_monitored_wells",
"project_areas",
}.issubset(ids)
+ # Hidden from the public catalog: locations duplicates the thing-type
+ # layers (BDMS-978), avg_tds_wells averages ~1.9 observations per well
+ # and latest_depth_to_water_wells repeats water_well_summary
+ # (BDMS-977), and other_things is internal vocabulary (BDMS-979). The
+ # backing relations are retained and still served on /ogcapi-internal.
+ assert ids.isdisjoint(
+ {
+ "locations",
+ "avg_tds_wells",
+ "latest_depth_to_water_wells",
+ "other_things",
+ }
+ )
def test_ogc_new_collection_items_endpoints(ogc_client):
@@ -605,16 +617,6 @@ def test_ogc_project_areas_items_expose_groups_with_project_areas(ogc_client, gr
assert str(group.id) in ids
-@pytest.mark.skip("PostGIS spatial operators not available in CI - see issue #449")
-def test_ogc_locations_items_bbox(location):
- bbox = "-107.95,33.80,-107.94,33.81"
- response = ogc_client.get(f"/ogcapi/collections/locations/items?bbox={bbox}")
- assert response.status_code == 200
- payload = response.json()
- assert payload["type"] == "FeatureCollection"
- assert payload["numberReturned"] >= 1
-
-
def test_ogc_wells_items_and_item(ogc_client, water_well_thing):
response = ogc_client.get("/ogcapi/collections/water_wells/items?limit=20")
assert response.status_code == 200
diff --git a/tests/test_pygeoapi_mount.py b/tests/test_pygeoapi_mount.py
index e3457a75c..ee9b11df8 100644
--- a/tests/test_pygeoapi_mount.py
+++ b/tests/test_pygeoapi_mount.py
@@ -109,3 +109,28 @@ def test_loading_a_mount_restores_config_env_vars():
after = {key: os.environ.get(key) for key in pygeoapi._PYGEOAPI_ENV_KEYS}
assert after == before
+
+
+# Layers hidden from the public catalog but still served to staff GIS
+# clients on /ogcapi-internal: locations duplicates the thing-type layers
+# (BDMS-978), avg_tds_wells and latest_depth_to_water_wells are misleading
+# or redundant (BDMS-977), other_things is internal vocabulary (BDMS-979).
+INTERNAL_ONLY_COLLECTIONS = {
+ "locations",
+ "avg_tds_wells",
+ "latest_depth_to_water_wells",
+ "other_things",
+}
+
+
+def test_hidden_layers_are_internal_only():
+ public_module, internal_module = _load_both()
+
+ public_ids = set(public_module.api_.config["resources"])
+ internal_ids = set(internal_module.api_.config["resources"])
+
+ assert public_ids.isdisjoint(INTERNAL_ONLY_COLLECTIONS)
+ assert INTERNAL_ONLY_COLLECTIONS.issubset(internal_ids)
+ # The thing-type layers that stay public are on both mounts; the two
+ # catalogs otherwise differ (the geothermal layers are public-only).
+ assert {"water_wells", "springs"}.issubset(public_ids & internal_ids)
From 572cf685cfa88f951ae707137effcbd8dea2775c Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sat, 22 Aug 2026 08:56:44 -0700
Subject: [PATCH 129/151] chore(ogc): explain what each layer is and how it was
built
Every collection carried a one-line description that mostly restated its
title -- "Trend classification for depth to water based on slope in feet
per year" tells a consumer nothing about which measurements went in, how
the slope was fitted, or when the answer is trustworthy. Keywords were
similarly thin, often three tokens echoing the layer id.
Each description now says, in plain language, how the layer is derived
and what it is for. The derivation text was written against the view SQL,
so the derived layers state the things a consumer cannot otherwise see:
* depth_to_water_trend_wells classifies at +/- 0.25 ft/yr and reports
"not enough data" below 10 readings (or 4 readings under two years),
and an increasing trend means the water table is falling.
* water_elevation_wells subtracts depth-to-water from surveyed ground
elevation, after converting metric readings to feet.
* The chemistry pivots normalize inconsistent legacy analyte names onto
one canonical set and keep the latest result per analyte, so analytes
at one well can carry different sampling dates.
* actively_monitored_wells requires both Water Level Network membership
and a current "Currently monitored" status.
* The three water-level layers state that a reading with no recorded
measuring-point height is treated as taken at ground level. That is
what the SQL does today; whether it is the right policy is BDMS-980
(A22) and is not decided here.
* The geothermal layers explain BHT, temperature-depth profiles, heat
flow and drill stem tests for readers who have not met the terms, and
note the Fahrenheit/Celsius mixing the source records carry.
The internal mount's copy of each layer gets the same text, and the two
internal-only layers say why they are not public: avg_tds_wells warns
that its mean rests on ~1.9 analyses per well, and
latest_depth_to_water_wells points at water_well_summary.
test_every_collection_description_explains_the_layer holds the floor:
substantive length, no placeholder wording, complete sentences, and
lowercase hyphenated keyword tokens. The token check is not cosmetic --
YAML folds a line break into a space, so a hyphenated word wrapped
across lines reaches consumers as "measuring- point".
Co-Authored-By: Claude Opus 5
---
core/pygeoapi-config-internal.yml | 160 +++++++++++++++++++---
core/pygeoapi-config.yml | 216 +++++++++++++++++++++++++----
core/pygeoapi.py | 219 +++++++++++++++++++++++++-----
tests/test_pygeoapi_mount.py | 37 +++++
4 files changed, 551 insertions(+), 81 deletions(-)
diff --git a/core/pygeoapi-config-internal.yml b/core/pygeoapi-config-internal.yml
index f2ddb5001..c1622bb6c 100644
--- a/core/pygeoapi-config-internal.yml
+++ b/core/pygeoapi-config-internal.yml
@@ -50,8 +50,15 @@ resources:
locations:
type: collection
title: Locations
- description: Geographic locations and site coordinates used by Ocotillo features.
- keywords: [locations]
+ description: >-
+ The raw geographic location records that every monitoring point hangs
+ off -- one feature per surveyed site, with its elevation, county,
+ quadrangle, and the notes recorded about how the coordinates were
+ obtained and how reliable they are. Most consumers want a feature-type
+ layer such as water_wells instead, which pairs the same coordinates
+ with what is actually monitored there; this layer is kept for staff
+ work that needs the location record itself.
+ keywords: [locations, sites, coordinates, elevation, county, data-reliability]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -73,8 +80,15 @@ resources:
latest_depth_to_water_wells:
type: collection
title: Latest Depth to Water (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]
+ description: >-
+ The most recent depth-to-water reading for each well, measured below
+ ground surface -- the measured depth minus the height of the measuring
+ point above ground, with readings that have no recorded
+ measuring-point height treated as taken at ground level.
+ water_well_summary publishes the same latest reading alongside the
+ count, range and trend of the whole record, so this layer is kept only
+ for staff clients that already depend on its narrower shape.
+ keywords: [water-wells, groundwater-level, depth-to-water, latest-value, below-ground-surface]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -96,8 +110,17 @@ resources:
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]
+ description: >-
+ The arithmetic mean of all total dissolved solids (TDS) results on
+ record for each water well. Treat with care: across the catalog the
+ average rests on about 1.9 analyses per well, so for many wells it is
+ a mean of one or two samples taken years apart and is not a reliable
+ summary of the well's water quality. Prefer latest_tds_wells, which
+ reports a single dated result.
+ keywords: [
+ water-wells, chemistry, tds, total-dissolved-solids, average,
+ low-sample-count, use-with-caution
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -119,8 +142,19 @@ resources:
latest_tds_wells:
type: collection
title: Latest TDS (Water Wells)
- description: Most recent total dissolved solids (TDS) result from major chemistry for each water well.
- keywords: [water-wells, chemistry, tds, total-dissolved-solids, latest]
+ description: >-
+ Total dissolved solids (TDS) measures how much mineral matter is
+ dissolved in the water -- in plain terms, how salty it is. This layer
+ reads every laboratory major-chemistry analysis on record for each
+ water well, keeps only the TDS results, and publishes the single most
+ recent one per well, dated by its analysis date or, where that is
+ missing, by the date the sample was collected. Use it for a current
+ statewide picture of groundwater salinity without working through each
+ well's full analysis history.
+ keywords: [
+ water-wells, water-quality, chemistry, tds, total-dissolved-solids,
+ salinity, latest-value, groundwater
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -142,8 +176,23 @@ resources:
depth_to_water_trend_wells:
type: collection
title: Depth to Water Trend (Water Wells)
- description: Trend classification for depth to water based on slope in feet per year.
- keywords: [water-wells, groundwater-level, depth-to-water, trend, slope]
+ description: >-
+ Shows whether the water table beneath each well has been falling,
+ rising, or holding steady. Every manual groundwater-level measurement
+ for the well is converted to a depth below ground surface -- the
+ measured depth minus the height of the measuring point above ground,
+ with readings that have no recorded measuring-point height treated as
+ taken at ground level -- and a straight line is fitted through those
+ depths over time. The slope of that line in feet per year is reported
+ as increasing (water table falling faster than 0.25 ft/yr), decreasing
+ (rising faster than 0.25 ft/yr), or stable. Wells with fewer than 10
+ measurements, or fewer than 4 spanning less than two years, are
+ labelled "not enough data" rather than given a trend the record cannot
+ support.
+ keywords: [
+ water-wells, groundwater-level, depth-to-water, trend, slope,
+ feet-per-year, declining-water-levels, aquifer-condition
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -165,8 +214,20 @@ resources:
water_elevation_wells:
type: collection
title: Water Elevation (Water Wells)
- description: Most recent water elevation per well calculated as elevation minus depth to water below ground surface.
- keywords: [water-wells, groundwater-level, water-elevation, depth-to-water]
+ description: >-
+ Gives the height of the water table above sea level at each well, so
+ that levels can be compared between wells standing at different ground
+ elevations. The most recent groundwater-level measurement is converted
+ to feet, the height of the measuring point above ground is subtracted
+ to give the depth below ground surface (readings with no recorded
+ measuring-point height are treated as taken at ground level), and that
+ depth is subtracted from the surveyed ground-surface elevation at the
+ well. Use it to map the shape of the water table or to work out which
+ way groundwater is flowing.
+ keywords: [
+ water-wells, groundwater-level, water-table-elevation, water-elevation,
+ depth-to-water, above-sea-level, groundwater-flow
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -188,8 +249,23 @@ resources:
water_well_summary:
type: collection
title: Water Well Summary
- description: Summary metrics per water well, including latest, min/max, and trend for water levels.
- keywords: [water-wells, summary, groundwater-level, trend]
+ description: >-
+ One row per water well, condensing that well's entire manual
+ groundwater-level record into a few numbers: how many measurements
+ exist, the most recent one and its date, the shallowest and deepest
+ ever recorded, and the long-term trend as a straight-line slope in
+ feet per year. Depths are below ground surface -- the measured depth
+ minus the height of the measuring point above ground, with readings
+ that have no recorded measuring-point height treated as taken at
+ ground level. Each row also carries the well's depth, its surveyed
+ ground elevation and how that elevation was determined, and the
+ geologic zone the well is completed in. Wells with no water-level
+ measurements at all are left out. Use it as the at-a-glance record for
+ a well before digging into individual readings.
+ keywords: [
+ water-wells, summary, groundwater-level, water-level-history, trend,
+ well-depth, elevation, at-a-glance
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -211,8 +287,22 @@ resources:
major_chemistry_results:
type: collection
title: Major Chemistry (Water Wells)
- description: Latest major chemistry analyte values for water wells, represented as static analyte columns.
- keywords: [water-wells, chemistry, analytes, major-chemistry]
+ description: >-
+ The major dissolved constituents that make up most of the chemistry of
+ groundwater -- calcium, magnesium, sodium, potassium, bicarbonate,
+ carbonate, sulfate and chloride -- alongside TDS, pH, hardness,
+ alkalinity and specific conductance. Laboratory records name the same
+ analyte in many different ways, so this layer first maps those names
+ and symbols onto one canonical set, then keeps the most recent result
+ for each analyte at each well and lays the values out as fixed
+ columns, each with its own units column. Analytes at one well may come
+ from different sampling dates; the reported chemistry date is the most
+ recent among them. Use it to compare water chemistry between wells or
+ to screen against drinking-water standards.
+ keywords: [
+ water-wells, water-quality, chemistry, major-ions, analytes, calcium,
+ sodium, chloride, sulfate, ph, hardness
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -234,8 +324,20 @@ resources:
minor_chemistry_wells:
type: collection
title: Minor Chemistry (Water Wells)
- description: Latest minor/trace chemistry analyte values for water wells, represented as static analyte columns.
- keywords: [water-wells, chemistry, analytes, minor-chemistry, trace-chemistry]
+ description: >-
+ Trace elements and isotopes measured in groundwater -- arsenic,
+ uranium, lead, iron, manganese, boron, lithium and dozens more, plus
+ the stable isotopes and carbon-14 used to work out how long water has
+ been underground. Built the same way as the major chemistry layer:
+ legacy laboratory records are mapped onto one canonical analyte set,
+ the most recent result for each analyte at each well is kept, and the
+ values are laid out as fixed columns each with its own units column.
+ Use it for contaminant screening and for questions about the age and
+ origin of groundwater.
+ keywords: [
+ water-wells, water-quality, chemistry, trace-elements, minor-chemistry,
+ isotopes, arsenic, uranium, carbon-14, contaminants
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -257,8 +359,18 @@ resources:
actively_monitored_wells:
type: collection
title: Actively Monitored Wells
- description: Wells in the collaborative network currently flagged as actively monitored.
- keywords: [water-wells, monitoring, collaborative-network, actively-monitored]
+ description: >-
+ The wells being measured today, rather than every well ever recorded.
+ A well appears here only if it belongs to the Water Level Network
+ group and its most recent monitoring-status entry reads "Currently
+ monitored"; the summary statistics attached to each one are the same
+ water-level figures published in water_well_summary. Use it to see the
+ live monitoring network -- where measurements are still being
+ collected, and where coverage is thin.
+ keywords: [
+ water-wells, monitoring, water-level-network, actively-monitored,
+ monitoring-network, groundwater-level
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -280,8 +392,12 @@ resources:
project_areas:
type: collection
title: Project Areas
- description: Project groups with polygon project-area boundaries.
- keywords: [project-areas, groups, boundaries]
+ description: >-
+ The study-area boundaries of Bureau projects, as polygons. Any project
+ group that has a mapped boundary is published here with its name and
+ description. Use it to see which part of New Mexico a project covers,
+ or to clip the other layers to a project's footprint.
+ keywords: [project-areas, study-areas, boundaries, polygons, projects, groups]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml
index c82fcea2a..902ecd8a0 100644
--- a/core/pygeoapi-config.yml
+++ b/core/pygeoapi-config.yml
@@ -52,8 +52,19 @@ resources:
latest_tds_wells:
type: collection
title: Latest TDS (Water Wells)
- description: Most recent total dissolved solids (TDS) result from major chemistry for each water well.
- keywords: [water-wells, chemistry, tds, total-dissolved-solids, latest]
+ description: >-
+ Total dissolved solids (TDS) measures how much mineral matter is
+ dissolved in the water -- in plain terms, how salty it is. This layer
+ reads every laboratory major-chemistry analysis on record for each
+ water well, keeps only the TDS results, and publishes the single most
+ recent one per well, dated by its analysis date or, where that is
+ missing, by the date the sample was collected. Use it for a current
+ statewide picture of groundwater salinity without working through each
+ well's full analysis history.
+ keywords: [
+ water-wells, water-quality, chemistry, tds, total-dissolved-solids,
+ salinity, latest-value, groundwater
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -75,8 +86,23 @@ resources:
depth_to_water_trend_wells:
type: collection
title: Depth to Water Trend (Water Wells)
- description: Trend classification for depth to water based on slope in feet per year.
- keywords: [water-wells, groundwater-level, depth-to-water, trend, slope]
+ description: >-
+ Shows whether the water table beneath each well has been falling,
+ rising, or holding steady. Every manual groundwater-level measurement
+ for the well is converted to a depth below ground surface -- the
+ measured depth minus the height of the measuring point above ground,
+ with readings that have no recorded measuring-point height treated as
+ taken at ground level -- and a straight line is fitted through those
+ depths over time. The slope of that line in feet per year is reported
+ as increasing (water table falling faster than 0.25 ft/yr), decreasing
+ (rising faster than 0.25 ft/yr), or stable. Wells with fewer than 10
+ measurements, or fewer than 4 spanning less than two years, are
+ labelled "not enough data" rather than given a trend the record cannot
+ support.
+ keywords: [
+ water-wells, groundwater-level, depth-to-water, trend, slope,
+ feet-per-year, declining-water-levels, aquifer-condition
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -98,8 +124,20 @@ resources:
water_elevation_wells:
type: collection
title: Water Elevation (Water Wells)
- description: Most recent water elevation per well calculated as elevation minus depth to water below ground surface.
- keywords: [water-wells, groundwater-level, water-elevation, depth-to-water]
+ description: >-
+ Gives the height of the water table above sea level at each well, so
+ that levels can be compared between wells standing at different ground
+ elevations. The most recent groundwater-level measurement is converted
+ to feet, the height of the measuring point above ground is subtracted
+ to give the depth below ground surface (readings with no recorded
+ measuring-point height are treated as taken at ground level), and that
+ depth is subtracted from the surveyed ground-surface elevation at the
+ well. Use it to map the shape of the water table or to work out which
+ way groundwater is flowing.
+ keywords: [
+ water-wells, groundwater-level, water-table-elevation, water-elevation,
+ depth-to-water, above-sea-level, groundwater-flow
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -121,8 +159,23 @@ resources:
water_well_summary:
type: collection
title: Water Well Summary
- description: Summary metrics per water well, including latest, min/max, and trend for water levels.
- keywords: [water-wells, summary, groundwater-level, trend]
+ description: >-
+ One row per water well, condensing that well's entire manual
+ groundwater-level record into a few numbers: how many measurements
+ exist, the most recent one and its date, the shallowest and deepest
+ ever recorded, and the long-term trend as a straight-line slope in
+ feet per year. Depths are below ground surface -- the measured depth
+ minus the height of the measuring point above ground, with readings
+ that have no recorded measuring-point height treated as taken at
+ ground level. Each row also carries the well's depth, its surveyed
+ ground elevation and how that elevation was determined, and the
+ geologic zone the well is completed in. Wells with no water-level
+ measurements at all are left out. Use it as the at-a-glance record for
+ a well before digging into individual readings.
+ keywords: [
+ water-wells, summary, groundwater-level, water-level-history, trend,
+ well-depth, elevation, at-a-glance
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -144,8 +197,22 @@ resources:
major_chemistry_results:
type: collection
title: Major Chemistry (Water Wells)
- description: Latest major chemistry analyte values for water wells, represented as static analyte columns.
- keywords: [water-wells, chemistry, analytes, major-chemistry]
+ description: >-
+ The major dissolved constituents that make up most of the chemistry of
+ groundwater -- calcium, magnesium, sodium, potassium, bicarbonate,
+ carbonate, sulfate and chloride -- alongside TDS, pH, hardness,
+ alkalinity and specific conductance. Laboratory records name the same
+ analyte in many different ways, so this layer first maps those names
+ and symbols onto one canonical set, then keeps the most recent result
+ for each analyte at each well and lays the values out as fixed
+ columns, each with its own units column. Analytes at one well may come
+ from different sampling dates; the reported chemistry date is the most
+ recent among them. Use it to compare water chemistry between wells or
+ to screen against drinking-water standards.
+ keywords: [
+ water-wells, water-quality, chemistry, major-ions, analytes, calcium,
+ sodium, chloride, sulfate, ph, hardness
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -167,8 +234,20 @@ resources:
minor_chemistry_wells:
type: collection
title: Minor Chemistry (Water Wells)
- description: Latest minor/trace chemistry analyte values for water wells, represented as static analyte columns.
- keywords: [water-wells, chemistry, analytes, minor-chemistry, trace-chemistry]
+ description: >-
+ Trace elements and isotopes measured in groundwater -- arsenic,
+ uranium, lead, iron, manganese, boron, lithium and dozens more, plus
+ the stable isotopes and carbon-14 used to work out how long water has
+ been underground. Built the same way as the major chemistry layer:
+ legacy laboratory records are mapped onto one canonical analyte set,
+ the most recent result for each analyte at each well is kept, and the
+ values are laid out as fixed columns each with its own units column.
+ Use it for contaminant screening and for questions about the age and
+ origin of groundwater.
+ keywords: [
+ water-wells, water-quality, chemistry, trace-elements, minor-chemistry,
+ isotopes, arsenic, uranium, carbon-14, contaminants
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -190,8 +269,18 @@ resources:
actively_monitored_wells:
type: collection
title: Actively Monitored Wells
- description: Wells in the collaborative network currently flagged as actively monitored.
- keywords: [water-wells, monitoring, collaborative-network, actively-monitored]
+ description: >-
+ The wells being measured today, rather than every well ever recorded.
+ A well appears here only if it belongs to the Water Level Network
+ group and its most recent monitoring-status entry reads "Currently
+ monitored"; the summary statistics attached to each one are the same
+ water-level figures published in water_well_summary. Use it to see the
+ live monitoring network -- where measurements are still being
+ collected, and where coverage is thin.
+ keywords: [
+ water-wells, monitoring, water-level-network, actively-monitored,
+ monitoring-network, groundwater-level
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -213,8 +302,12 @@ resources:
project_areas:
type: collection
title: Project Areas
- description: Project groups with polygon project-area boundaries.
- keywords: [project-areas, groups, boundaries]
+ description: >-
+ The study-area boundaries of Bureau projects, as polygons. Any project
+ group that has a mapped boundary is published here with its name and
+ description. Use it to see which part of New Mexico a project covers,
+ or to clip the other layers to a project's footprint.
+ keywords: [project-areas, study-areas, boundaries, polygons, projects, groups]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -238,8 +331,21 @@ resources:
geothermal_wells_bht:
type: collection
title: Geothermal Wells — Bottom-Hole Temperature
- description: Geothermal wells with bottom-hole temperature (BHT) measurements from the NM_Wells database.
- keywords: [geothermal, wells, bottom-hole-temperature, bht]
+ description: >-
+ Bottom-hole temperature (BHT) is the temperature at the deepest point
+ of a borehole, usually recorded while drilling, and is the cheapest
+ broad indicator of how hot the subsurface is. This layer rolls every
+ BHT reading for a well in the legacy NM_Wells oil, gas and geothermal
+ records up into a single point: how many readings exist, the hottest
+ and coolest, the depth of the deepest, and those temperatures
+ converted to degrees Celsius. Source records mix Fahrenheit and
+ Celsius, so each well also carries a flag when its readings arrived in
+ mixed units and a count of any that could not be converted. Use it to
+ find warm areas worth closer investigation.
+ keywords: [
+ geothermal, bottom-hole-temperature, bht, subsurface-temperature, wells,
+ nm-wells, celsius, heat-resource
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -261,8 +367,20 @@ resources:
geothermal_wells_temperature_profile:
type: collection
title: Geothermal Wells — Temperature-Depth Profile
- description: Geothermal wells with downhole temperature-vs-depth series from the NM_Wells database.
- keywords: [geothermal, wells, temperature, depth, profile]
+ description: >-
+ How temperature changes with depth down a borehole, summarised as one
+ point per well. Every temperature-versus-depth reading logged for the
+ well is gathered into a single record: the number of readings, the
+ depth range they cover, the coolest and hottest values in degrees
+ Celsius, and the whole profile as a list of depth/temperature pairs.
+ Mixed source temperature units are flagged as they are for bottom-hole
+ temperatures. Use it to estimate the geothermal gradient -- how
+ quickly the ground warms with depth -- without pulling every
+ individual reading.
+ keywords: [
+ geothermal, temperature-profile, temperature-depth, geothermal-gradient,
+ wells, nm-wells, celsius
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -284,8 +402,20 @@ resources:
bht_measurements:
type: collection
title: BHT Measurements
- description: Individual bottom-hole temperature measurements with well header and location data from the NM_Wells database.
- keywords: [geothermal, bht, bottom-hole-temperature, measurements]
+ description: >-
+ Every individual bottom-hole temperature reading, one feature per
+ measurement, for consumers who need the raw record rather than the
+ per-well roll-up in geothermal_wells_bht. Each row carries the
+ temperature, the depth it was taken at, the date, and the hours since
+ drilling fluid was last circulated -- readings taken soon after
+ circulation are cooler than the rock itself, so that figure decides
+ whether a reading can be corrected. Well header details (operator,
+ well type, total depth, completion date, current status) and the
+ county are carried along for context.
+ keywords: [
+ geothermal, bht, bottom-hole-temperature, measurements, raw-readings,
+ drilling, nm-wells
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -307,8 +437,17 @@ resources:
temp_depth_measurements:
type: collection
title: Temperature-Depth Measurements
- description: Individual downhole temperature readings with well header, location, and elevation data from the NM_Wells database.
- keywords: [geothermal, temperature, depth, measurements]
+ description: >-
+ Every individual downhole temperature reading, one feature per
+ measurement, for consumers who need the raw record rather than the
+ per-well roll-up in geothermal_wells_temperature_profile. Each row
+ gives the temperature, the depth it was recorded at, the well it came
+ from and that well's elevation datum, so gradients can be recomputed
+ from scratch or checked against the summarised profile.
+ keywords: [
+ geothermal, temperature, temperature-depth, downhole, measurements,
+ raw-readings, nm-wells
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -330,8 +469,20 @@ resources:
heat_flow:
type: collection
title: Heat Flow
- description: Summary heat-flow records with thermal conductivity, gradient, and publication attribution from the NM_Wells database.
- keywords: [geothermal, heat-flow, thermal-conductivity, gradient]
+ description: >-
+ Heat flow is the rate at which the Earth's internal heat escapes
+ through the ground surface, and is the standard measure of geothermal
+ potential. Each row is one published determination over one depth
+ interval in one well, obtained by multiplying the temperature gradient
+ measured in the hole by the thermal conductivity of the rock. Values
+ recorded in the older heat-flow and conductivity units are republished
+ alongside SI equivalents (milliwatts per square metre, watts per
+ metre-kelvin), and every record carries the quality rating and the
+ literature citation it was published with.
+ keywords: [
+ geothermal, heat-flow, thermal-conductivity, thermal-gradient,
+ geothermal-potential, nm-wells, publications
+ ]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
@@ -353,8 +504,17 @@ resources:
dst:
type: collection
title: Drill Stem Tests
- description: Drill stem test intervals with pressure, flow history, and well header data from the NM_Wells database.
- keywords: [geothermal, dst, drill-stem-test, pressure, formation]
+ description: >-
+ A drill stem test is a temporary completion run while a well is still
+ being drilled: the drill pipe is opened against a chosen depth
+ interval so that formation fluid can flow in, and the pressures and
+ flow behaviour are recorded. Each row here is one tested interval --
+ its depth range, target formation, packer settings, choke sizes and
+ gauge depth -- with the sequence of operations logged during the test
+ joined together in order as its flow history. Use it for formation
+ pressure and fluid evidence in wells that were never completed for
+ production.
+ keywords: [drill-stem-test, dst, formation-pressure, flow-history, reservoir, wells, nm-wells]
extents:
spatial:
bbox: [-109.05, 31.33, -103.00, 37.00]
diff --git a/core/pygeoapi.py b/core/pygeoapi.py
index 849f4f381..a1d9f4aac 100644
--- a/core/pygeoapi.py
+++ b/core/pygeoapi.py
@@ -19,61 +19,144 @@
"title": "Water Wells",
"thing_type": "water well",
"description": (
- "Groundwater wells used for monitoring, production, and "
- "hydrogeologic investigations."
+ "Groundwater wells: drilled or dug access points into an aquifer, "
+ "used for monitoring, production, and hydrogeologic investigation. "
+ "Each feature is one well from the monitoring-point register, placed "
+ "at the most recent location recorded for it, and carries the "
+ "construction details held for it -- total and hole depth, casing "
+ "diameter and depth, completion date, driller, construction method, "
+ "pump type and depth, and the geologic formation it is completed in. "
+ "This is the starting point for groundwater work: the water-level and "
+ "chemistry layers are all derived from these same wells."
),
- "keywords": ["well", "groundwater", "water-well"],
+ "keywords": [
+ "water-wells",
+ "wells",
+ "groundwater",
+ "aquifer",
+ "monitoring-points",
+ "well-construction",
+ ],
},
{
"id": "springs",
"title": "Springs",
"thing_type": "spring",
"description": (
- "Natural spring features and associated spring monitoring points."
+ "Springs: places where groundwater reaches the land surface under its "
+ "own pressure, without pumping. Each feature is one spring from the "
+ "monitoring-point register, placed at the most recent location "
+ "recorded for it. Use it to map natural groundwater discharge, the "
+ "groundwater contribution to streamflow, and the water sources that "
+ "support desert ecosystems."
),
- "keywords": ["springs", "groundwater-discharge"],
+ "keywords": [
+ "springs",
+ "groundwater-discharge",
+ "monitoring-points",
+ "surface-water",
+ "seeps",
+ ],
},
{
"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."
+ "Surface-water diversions: structures that take water out of a "
+ "stream, river, or canal -- ditches, acequias, headgates and intakes. "
+ "Each feature is one diversion from the monitoring-point register, "
+ "placed at the most recent location recorded for it. Use it to see "
+ "where surface water is withdrawn and to pair those points with "
+ "downstream flow records."
),
- "keywords": ["surface-water", "diversion"],
+ "keywords": [
+ "surface-water",
+ "diversion",
+ "ditches",
+ "acequias",
+ "headgates",
+ "monitoring-points",
+ ],
},
{
"id": "ephemeral_streams",
"title": "Ephemeral Streams",
"thing_type": "ephemeral stream",
"description": (
- "Stream reaches that flow only in direct response to "
- "precipitation events."
+ "Ephemeral stream reaches: channels that carry water only in direct "
+ "response to rain or snowmelt and are dry the rest of the year. Each "
+ "feature is one monitored reach from the register, placed at the most "
+ "recent location recorded for it. Use it for flash-flow and "
+ "storm-response work, and to distinguish these channels from reaches "
+ "that flow year-round."
),
- "keywords": ["ephemeral-stream", "surface-water"],
+ "keywords": [
+ "ephemeral-stream",
+ "surface-water",
+ "intermittent-flow",
+ "storm-response",
+ "monitoring-points",
+ ],
},
{
"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"],
+ "description": (
+ "Standing bodies of surface water monitored as sites -- natural "
+ "lakes, ponds, and built reservoirs. Each feature is one water body "
+ "from the monitoring-point register, placed at the most recent "
+ "location recorded for it. Use it for storage and surface-water "
+ "quality work, and as context for nearby groundwater levels."
+ ),
+ "keywords": [
+ "lake",
+ "pond",
+ "reservoir",
+ "surface-water",
+ "storage",
+ "monitoring-points",
+ ],
},
{
"id": "meteorological_stations",
"title": "Meteorological Stations",
"thing_type": "meteorological station",
- "description": "Weather and climate monitoring station locations.",
- "keywords": ["meteorological-station", "weather"],
+ "description": (
+ "Weather and climate stations: sites that record conditions such as "
+ "precipitation, temperature, and evaporation. Each feature is one "
+ "station from the monitoring-point register, placed at the most "
+ "recent location recorded for it. Use it to relate groundwater and "
+ "streamflow behaviour to the weather that drives it."
+ ),
+ "keywords": [
+ "meteorological-station",
+ "weather",
+ "climate",
+ "precipitation",
+ "monitoring-points",
+ ],
},
{
"id": "other_things",
"title": "Other Thing Types",
"thing_type": "other",
"description": (
- "Feature records that do not match another defined thing type."
+ "Monitoring points that do not fall into any of the defined feature "
+ "types. Each feature is one such point from the register, placed at "
+ "the most recent location recorded for it. The set is small and "
+ "mixed, with no shared meaning between its members, so it is "
+ "published only on the internal mount for staff triage -- typically "
+ "to find records that need reclassifying."
),
- "keywords": ["other"],
+ "keywords": [
+ "other",
+ "unclassified",
+ "monitoring-points",
+ "internal",
+ "triage",
+ ],
# "Thing" is internal data-model vocabulary and "other" names no
# recognisable feature class, so this layer is not published on the
# public mount (BDMS-979). Staff GIS clients still reach it through
@@ -84,31 +167,79 @@
"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"],
+ "description": (
+ "Outfalls and return flow: points where treated wastewater or unused "
+ "irrigation water re-enters a stream or channel. Each feature is one "
+ "outfall from the monitoring-point register, placed at the most "
+ "recent location recorded for it. Use it in water-quality work, where "
+ "these points mark deliberate inputs to a watercourse."
+ ),
+ "keywords": [
+ "outfall",
+ "return-flow",
+ "wastewater",
+ "surface-water",
+ "water-quality",
+ "monitoring-points",
+ ],
},
{
"id": "perennial_streams",
"title": "Perennial Streams",
"thing_type": "perennial stream",
- "description": ("Stream reaches with continuous or near-continuous flow."),
- "keywords": ["perennial-stream", "surface-water"],
+ "description": (
+ "Perennial stream reaches: channels that flow year-round in most "
+ "years, sustained between storms by groundwater discharge. Each "
+ "feature is one monitored reach from the register, placed at the most "
+ "recent location recorded for it. Use it for base-flow and "
+ "surface-water/groundwater interaction work."
+ ),
+ "keywords": [
+ "perennial-stream",
+ "surface-water",
+ "base-flow",
+ "streamflow",
+ "monitoring-points",
+ ],
},
{
"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"],
+ "description": (
+ "Places where rock samples were collected or outcrop geology was "
+ "documented. Each feature is one sample location from the "
+ "monitoring-point register, placed at the most recent location "
+ "recorded for it. Use it to find where physical samples backing "
+ "geologic mapping and laboratory analysis came from."
+ ),
+ "keywords": [
+ "rock-sample",
+ "geology",
+ "sample-location",
+ "outcrop",
+ "monitoring-points",
+ ],
},
{
"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."
+ "Places where gas held in the pore space of soil was sampled. Each "
+ "feature is one sample location from the monitoring-point register, "
+ "placed at the most recent location recorded for it. Soil gas is used "
+ "to detect vapours rising from buried contamination or from geologic "
+ "sources, so these points usually mark contamination or "
+ "resource-exploration surveys."
),
- "keywords": ["soil-gas", "sample-location"],
+ "keywords": [
+ "soil-gas",
+ "sample-location",
+ "vapour-survey",
+ "contamination",
+ "monitoring-points",
+ ],
},
]
@@ -120,11 +251,25 @@
"id": "waterlevels",
"title": "Water Levels",
"description": (
- "Depth-to-water observations (manual readings and continuous "
- "transducer time series) served as OGC API - EDR coverages. "
- "Each transducer deployment is exposed as an EDR instance."
+ "Depth-to-water through time at each well, served as time series "
+ "rather than as one point per well. Two kinds of record are combined: "
+ "manual measurements taken by field staff during a visit, and "
+ "continuous records from pressure transducers left down the well, "
+ "which log automatically at a fixed interval. Each transducer "
+ "deployment is exposed as its own EDR instance, so a well's record "
+ "can be read deployment by deployment or as a whole. Use it to plot "
+ "hydrographs and to see how water levels respond to pumping, "
+ "recharge, and drought."
),
- "keywords": ["groundwater", "water-level", "depth-to-water", "edr"],
+ "keywords": [
+ "groundwater",
+ "water-level",
+ "depth-to-water",
+ "time-series",
+ "hydrograph",
+ "transducer",
+ "edr",
+ ],
"table": "ogc_waterlevels",
"instance_field": "deployment_id",
},
@@ -132,10 +277,22 @@
"id": "water_chemistry",
"title": "Water Chemistry",
"description": (
- "Water-chemistry analyses keyed by analyte, served as OGC API - "
- "EDR coverages."
+ "Water-chemistry analyses through time, one record per analyte per "
+ "sample, served as time series. The layer draws together the major, "
+ "minor and trace, and field-parameter analysis records from the "
+ "legacy chemistry tables, keyed by the analyte name as the laboratory "
+ "recorded it. Use it to follow one constituent at one site over time "
+ "-- the chemistry feature layers, by contrast, give the latest value "
+ "for every analyte at once."
),
- "keywords": ["water-chemistry", "analyte", "edr"],
+ "keywords": [
+ "water-chemistry",
+ "water-quality",
+ "analyte",
+ "time-series",
+ "laboratory-results",
+ "edr",
+ ],
"table": "ogc_water_chemistry",
"instance_field": None,
},
diff --git a/tests/test_pygeoapi_mount.py b/tests/test_pygeoapi_mount.py
index ee9b11df8..e267d1803 100644
--- a/tests/test_pygeoapi_mount.py
+++ b/tests/test_pygeoapi_mount.py
@@ -14,6 +14,7 @@
"""
import os
+import re
import sys
from core import pygeoapi
@@ -134,3 +135,39 @@ def test_hidden_layers_are_internal_only():
# The thing-type layers that stay public are on both mounts; the two
# catalogs otherwise differ (the geothermal layers are public-only).
assert {"water_wells", "springs"}.issubset(public_ids & internal_ids)
+
+
+# Wording that says nothing to a consumer reading the catalog cold: internal
+# data-model vocabulary, or a description that only restates the layer name.
+PLACEHOLDER_TERMS = ("todo", "tbd", "xxx", "placeholder", "example.com", "lorem")
+
+
+def test_every_collection_description_explains_the_layer():
+ # A description has to tell a non-specialist how the layer was derived and
+ # what it is for -- not repeat the title. Short entries are the failure
+ # mode this guards: they are what the catalog shipped with before.
+ for module in _load_both():
+ for name, resource in module.api_.config["resources"].items():
+ description = resource.get("description", "")
+ lowered = description.lower()
+ assert len(description) >= 200, f"{name} description is too thin"
+ assert not any(
+ term in lowered for term in PLACEHOLDER_TERMS
+ ), f"{name} description contains placeholder wording"
+ assert description.rstrip().endswith(
+ "."
+ ), f"{name} description is not a complete sentence"
+ # YAML folds a line break into a space, so a hyphenated word split
+ # across lines ("measuring-\npoint") reaches consumers as
+ # "measuring- point". Wrap on whitespace only.
+ assert not re.search(
+ r"\w- \w", description
+ ), f"{name} description has a hyphenated word split across lines"
+
+ keywords = resource.get("keywords", [])
+ assert len(keywords) >= 4, f"{name} has too few keywords"
+ assert len(set(keywords)) == len(keywords), f"{name} repeats a keyword"
+ for keyword in keywords:
+ assert re.fullmatch(
+ r"[a-z0-9]+(?:-[a-z0-9]+)*", keyword
+ ), f"{name} keyword {keyword!r} is not a lowercase hyphenated token"
From 832b659b18b3c186bfa7b186df6fa44b0b616276 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sat, 22 Aug 2026 09:45:07 -0700
Subject: [PATCH 130/151] feat(ogc): add the field-description source of truth
Collection-level prose landed in the previous commit; below it there is
still nothing. A client asking what depth_to_water_bgs or
hours_since_circulation means gets back a column name and a JSON type.
core/ogc-field-descriptions.yml holds a title, a description, and a unit
where one applies, for every column of every published collection. It is
keyed by backing relation with the ogc_/ogc_internal_ prefix stripped, so
the two mounts share one entry per view and the provider can look itself
up from self.table with no new plumbing. A _defaults block covers the
columns shared across views -- the 11 thing-type views have one identical
18-column signature between them.
Rejected: COMMENT ON COLUMN on the ogc_* views. It would put the prose
next to the data, but every wording fix would need an Alembic revision
and a matview rebuild, and pygeoapi's reflection does not read comments,
so a catalog query would be needed regardless.
describe_fields() returns fresh dicts rather than references into the
cached YAML. That is load-bearing: pygeoapi's get_collection_schema
assigns the provider's own field dict into its response and then mutates
it in place, so shared references would let one request's x-ogc-role
assignment leak into every later response.
A missing entry is a generated title and a logged warning, never an
error. Drift is caught by tests, not at runtime.
The 190 chemistry analyte columns are generated by
cli/generate_chemistry_field_descriptions.py and reviewed by hand. It
reads the analyte lists out of the view migration rather than the
parameter lexicon, which holds only two field parameters.
Co-Authored-By: Claude Opus 5
---
cli/generate_chemistry_field_descriptions.py | 269 +++
core/ogc-field-descriptions.yml | 2046 ++++++++++++++++++
core/ogc_field_metadata.py | 154 ++
tests/test_ogc_field_metadata.py | 123 ++
4 files changed, 2592 insertions(+)
create mode 100644 cli/generate_chemistry_field_descriptions.py
create mode 100644 core/ogc-field-descriptions.yml
create mode 100644 core/ogc_field_metadata.py
create mode 100644 tests/test_ogc_field_metadata.py
diff --git a/cli/generate_chemistry_field_descriptions.py b/cli/generate_chemistry_field_descriptions.py
new file mode 100644
index 000000000..6ad1811dd
--- /dev/null
+++ b/cli/generate_chemistry_field_descriptions.py
@@ -0,0 +1,269 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Emit the chemistry blocks of ``core/ogc-field-descriptions.yml``.
+
+``ogc_major_chemistry_results`` and ``ogc_minor_chemistry_wells`` publish one
+column per analyte plus a paired units column -- 190 columns between them.
+Hand-writing that is error-prone, so this script generates it and the output is
+reviewed and committed. Run it again when the analyte lists change:
+
+ uv run python -m cli.generate_chemistry_field_descriptions > /tmp/chem.yml
+
+Source of truth is the analyte lists in the migration that builds the two
+views, which are the column names themselves. (``core/parameter.json`` holds
+only two field parameters, so the lexicon cannot supply this.)
+
+Analytes needing more than a one-line gloss are spelled out in ANALYTE_PROSE;
+anything absent falls back to a generated title and a stock description. Prose
+here loses to a hand-written entry in the YAML, which wins on merge.
+"""
+
+import importlib.util
+import sys
+import textwrap
+from pathlib import Path
+
+MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py"
+)
+
+# Analyte key -> (title, description). Everything else gets a generated title
+# and the stock "dissolved concentration" line.
+ANALYTE_PROSE = {
+ "tds": (
+ "Total dissolved solids",
+ "Total mass of dissolved mineral matter in the water -- in plain terms, "
+ "how salty it is. Drinking-water guidance sits around 500 mg/L.",
+ ),
+ "ph": (
+ "pH",
+ "Acidity of the water on the 0-14 scale, where 7 is neutral. Unitless. "
+ "Most New Mexico groundwater falls between 7 and 8.5.",
+ ),
+ "specific_conductance": (
+ "Specific conductance",
+ "How well the water conducts electricity, which rises with dissolved "
+ "mineral content. Used as a fast field proxy for total dissolved solids.",
+ ),
+ "hardness": (
+ "Hardness",
+ "Combined calcium and magnesium content, reported as an equivalent mass "
+ "of calcium carbonate. What determines whether water is 'hard'.",
+ ),
+ "alkalinity": (
+ "Alkalinity",
+ "The water's capacity to neutralise acid, reported as an equivalent mass "
+ "of calcium carbonate. Mostly supplied by bicarbonate and carbonate.",
+ ),
+ "ion_balance": (
+ "Ion balance",
+ "Percentage difference between the total positive and total negative "
+ "charge in the analysis. Charge must balance in reality, so a figure far "
+ "from zero means the analysis is incomplete or in error.",
+ ),
+ "total_cations": (
+ "Total cations",
+ "Sum of the positively charged dissolved constituents in the analysis.",
+ ),
+ "total_anions": (
+ "Total anions",
+ "Sum of the negatively charged dissolved constituents in the analysis.",
+ ),
+ "sodium_plus_potassium": (
+ "Sodium plus potassium",
+ "Combined sodium and potassium concentration, reported together where the "
+ "laboratory did not separate them.",
+ ),
+ "nitrate": (
+ "Nitrate",
+ "Dissolved nitrate concentration, usually from fertiliser, septic systems, "
+ "or livestock. The drinking-water limit is 10 mg/L as nitrogen.",
+ ),
+ "nitrate_as_n": (
+ "Nitrate as nitrogen",
+ "Nitrate concentration expressed as the mass of nitrogen alone, which is "
+ "how the 10 mg/L drinking-water limit is written. Roughly a quarter of the "
+ "same sample reported as nitrate.",
+ ),
+ "nitrite": (
+ "Nitrite",
+ "Dissolved nitrite concentration, an intermediate stage in the breakdown of "
+ "nitrogen compounds.",
+ ),
+ "silica": (
+ "Silica",
+ "Dissolved silica concentration, weathered out of silicate rock. Useful for "
+ "estimating the temperature water last equilibrated at.",
+ ),
+ "arsenic": (
+ "Arsenic",
+ "Dissolved arsenic concentration. Naturally elevated in parts of New Mexico "
+ "and regulated in drinking water at 0.010 mg/L.",
+ ),
+ "uranium": (
+ "Uranium",
+ "Dissolved uranium concentration. Naturally present near uranium-bearing "
+ "rock and regulated in drinking water at 0.030 mg/L.",
+ ),
+ "fluoride": (
+ "Fluoride",
+ "Dissolved fluoride concentration. Beneficial in small amounts; the "
+ "drinking-water limit is 4 mg/L.",
+ ),
+ "h2r": (
+ "Deuterium ratio",
+ "Ratio of heavy to ordinary hydrogen in the water, reported as per-mil "
+ "difference from ocean water. Fingerprints where the water fell as "
+ "precipitation.",
+ ),
+ "o18r": (
+ "Oxygen-18 ratio",
+ "Ratio of heavy to ordinary oxygen in the water, reported as per-mil "
+ "difference from ocean water. Read with the deuterium ratio to trace the "
+ "water's origin and evaporation history.",
+ ),
+ "c13r": (
+ "Carbon-13 ratio",
+ "Ratio of carbon-13 to carbon-12 in the water's dissolved carbon, reported "
+ "as per-mil difference from a standard. Helps identify where the carbon "
+ "came from, which is needed to correct a carbon-14 age.",
+ ),
+ "c14": (
+ "Carbon-14",
+ "Carbon-14 remaining in the water's dissolved carbon, as a percentage of "
+ "the modern atmospheric level. The basis for dating groundwater up to "
+ "roughly 40,000 years old.",
+ ),
+ "c14_years": (
+ "Carbon-14 age",
+ "Apparent age of the water in years, calculated from its carbon-14 content. "
+ "Uncorrected for carbon picked up from rock, so treat it as an upper bound.",
+ ),
+ "bromide": (
+ "Bromide",
+ "Dissolved bromide concentration. Read against chloride, it distinguishes "
+ "seawater-derived salinity from dissolved halite.",
+ ),
+}
+
+# Elements whose column name is not the plain element name.
+ELEMENT_NAMES = {
+ "silicon": "silicon",
+ "molybdenum": "molybdenum",
+ "strontium": "strontium",
+}
+
+STOCK_DESCRIPTION = (
+ "Dissolved {name} concentration in the most recent sample analysed for it."
+)
+TOTAL_DESCRIPTION = (
+ "Total {name} concentration -- the unfiltered determination, which counts "
+ "{name} bound to suspended particles as well as the dissolved fraction."
+)
+UNITS_DESCRIPTION = (
+ "Units the {title_lower} value is reported in, as the laboratory recorded them."
+)
+
+
+def _load_analyte_lists():
+ """Import the migration module by path and read its analyte column lists."""
+ spec = importlib.util.spec_from_file_location("_ogc_filter_migration", MIGRATION)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return (
+ [key for key, _ in module.STATIC_ANALYTE_COLUMNS_MAJOR],
+ [key for key, _ in module.STATIC_ANALYTE_COLUMNS_MINOR],
+ )
+
+
+def _entry(analyte_key: str):
+ if analyte_key in ANALYTE_PROSE:
+ return ANALYTE_PROSE[analyte_key]
+
+ if analyte_key.endswith("_total"):
+ base = analyte_key[: -len("_total")]
+ name = ELEMENT_NAMES.get(base, base).replace("_", " ")
+ title = f"{name.capitalize()} (total)"
+ return title, TOTAL_DESCRIPTION.format(name=name)
+
+ name = ELEMENT_NAMES.get(analyte_key, analyte_key).replace("_", " ")
+ return name.capitalize(), STOCK_DESCRIPTION.format(name=name)
+
+
+def _yaml_block(field: str, title: str, description: str) -> str:
+ body = textwrap.fill(
+ description,
+ width=74,
+ initial_indent=" " * 6,
+ subsequent_indent=" " * 6,
+ break_on_hyphens=False,
+ break_long_words=False,
+ )
+ return f" {field}:\n title: {title}\n description: >-\n{body}\n"
+
+
+def render(table: str, analyte_keys) -> str:
+ lines = [f"{table}:"]
+ lines.append(
+ _yaml_block(
+ "location_id",
+ "Location ID",
+ "Identifier of the location record the well's coordinates came from.",
+ )
+ )
+ lines.append(
+ _yaml_block(
+ "analyte_count",
+ "Analyte count",
+ "Number of distinct analytes with a value in this row. A low count "
+ "means the well has only been analysed for part of the suite.",
+ )
+ )
+ lines.append(
+ _yaml_block(
+ "latest_chemistry_date",
+ "Latest analysis date",
+ "Date of the most recent result in this row. Analytes are carried "
+ "forward independently, so an individual value may be older than "
+ "this date.",
+ )
+ )
+ for key in analyte_keys:
+ title, description = _entry(key)
+ lines.append(_yaml_block(key, title, description))
+ lines.append(
+ _yaml_block(
+ f"{key}_units",
+ f"{title} units",
+ UNITS_DESCRIPTION.format(title_lower=title.lower()),
+ )
+ )
+ return "\n".join(lines)
+
+
+def main() -> int:
+ major, minor = _load_analyte_lists()
+ print(
+ "# Generated by cli/generate_chemistry_field_descriptions.py -- review before committing."
+ )
+ print(render("major_chemistry_results", major))
+ print(render("minor_chemistry_wells", minor))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/core/ogc-field-descriptions.yml b/core/ogc-field-descriptions.yml
new file mode 100644
index 000000000..e81353d7b
--- /dev/null
+++ b/core/ogc-field-descriptions.yml
@@ -0,0 +1,2046 @@
+# Per-field documentation for the OGC collections.
+#
+# Keyed by backing relation with the ogc_/ogc_internal_ prefix stripped, so the
+# public and internal mounts share one entry per view. `_defaults` applies to
+# every table; a per-table entry wins over it.
+#
+# Allowed keys per field: title, description, x-ogc-unit, x-ogc-unitLang,
+# x-ogc-propertySeq. Types and formats come from provider reflection, never
+# from here.
+#
+# Say what the value means and what its datum or convention is -- not how the
+# view is assembled. That belongs in the collection description.
+#
+# See docs/ogc-field-descriptions.md.
+
+_defaults:
+ id:
+ title: Feature ID
+ description: >-
+ Stable identifier for this feature within the collection. Unique inside
+ the collection, not across collections.
+ name:
+ title: Name
+ description: >-
+ Name or identifier the monitoring point is known by, as recorded by the
+ Bureau.
+ thing_type:
+ title: Feature type
+ description: >-
+ Controlled-vocabulary type of the monitoring point, such as water well,
+ spring, or meteorological station.
+ release_status:
+ title: Release status
+ description: >-
+ Publication state of the record. Only records marked public appear on
+ the public /ogcapi mount; the authenticated internal mount also carries
+ private and draft records.
+ first_visit_date:
+ title: First visit date
+ description: Date of the earliest Bureau visit on record for this feature.
+ nma_pk_welldata:
+ title: Legacy NM_Aquifer well key
+ description: >-
+ Primary key of this feature's record in the legacy NM_Aquifer WellData
+ table, kept so migrated rows can be traced back to their source.
+ elevation:
+ title: Ground-surface elevation
+ description: >-
+ Surveyed elevation of the ground surface at the feature, in metres above
+ the NAVD 88 vertical datum.
+ x-ogc-unit: https://qudt.org/vocab/unit/M
+ x-ogc-unitLang: QUDT
+ well_depth:
+ title: Well depth
+ description: >-
+ Total depth of the finished well, from ground surface to the bottom of
+ the well.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT
+ x-ogc-unitLang: QUDT
+ hole_depth:
+ title: Borehole depth
+ description: >-
+ Depth of the drilled hole, from ground surface to the bottom of the
+ borehole. Usually deeper than the finished well.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT
+ x-ogc-unitLang: QUDT
+ well_casing_diameter:
+ title: Casing diameter
+ description: Inside diameter of the well casing.
+ x-ogc-unit: https://qudt.org/vocab/unit/IN
+ x-ogc-unitLang: QUDT
+ well_casing_depth:
+ title: Casing depth
+ description: >-
+ Depth from ground surface to the bottom of the well casing.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT
+ x-ogc-unitLang: QUDT
+ well_completion_date:
+ title: Completion date
+ description: Date the well was finished, where it is known.
+ well_driller_name:
+ title: Driller
+ description: Name of the driller or drilling company that constructed the well.
+ well_construction_method:
+ title: Construction method
+ description: >-
+ How the well was constructed, such as air rotary, cable tool, or dug,
+ from a controlled vocabulary.
+ well_pump_type:
+ title: Pump type
+ description: >-
+ Type of pump installed in the well, such as submersible or windmill,
+ from a controlled vocabulary.
+ well_pump_depth:
+ title: Pump intake depth
+ description: Depth from ground surface to the pump intake.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT
+ x-ogc-unitLang: QUDT
+ formation_completion_code:
+ title: Completion formation
+ description: >-
+ Geologic formation the well is completed in -- the formation it draws
+ from, not the full sequence of rock it passes through.
+ nma_formation_zone:
+ title: Legacy formation zone
+ description: >-
+ Formation zone exactly as recorded in the legacy NM_Aquifer WellData
+ table, kept unedited alongside the controlled-vocabulary value.
+ county:
+ title: County
+ description: New Mexico county the feature falls in.
+ state:
+ title: State
+ description: State the feature falls in.
+ api:
+ title: API number
+ description: >-
+ American Petroleum Institute well number, the standard unique identifier
+ for a drilled well in the United States.
+ well_name:
+ title: Well name
+ description: Name the well is recorded under in the legacy NM_Wells database.
+ well_num:
+ title: Well number
+ description: Operator's number for the well within its lease or unit.
+ well_data_id:
+ title: Legacy NM_Wells well key
+ description: >-
+ Identifier of the well's record in the legacy NM_Wells database, kept so
+ rows can be traced back to their source.
+ total_depth:
+ title: Total depth
+ description: Total drilled depth of the well, as reported on its record.
+ source_id:
+ title: Source ID
+ description: >-
+ Identifier of the publication or data submission the record came from,
+ in the legacy NM_Wells source register.
+ entry_date:
+ title: Record entry date
+ description: Date the record was entered into the legacy NM_Wells database.
+ lat_dd83:
+ title: Latitude (NAD 83)
+ description: Latitude in decimal degrees on the NAD 83 datum.
+ x-ogc-unit: https://qudt.org/vocab/unit/DEG
+ x-ogc-unitLang: QUDT
+ long_dd83:
+ title: Longitude (NAD 83)
+ description: Longitude in decimal degrees on the NAD 83 datum.
+ x-ogc-unit: https://qudt.org/vocab/unit/DEG
+ x-ogc-unitLang: QUDT
+ lat_dd27:
+ title: Latitude (NAD 27)
+ description: >-
+ Latitude in decimal degrees on the older NAD 27 datum, as originally
+ recorded. Positions differ from NAD 83 by roughly 100 metres.
+ x-ogc-unit: https://qudt.org/vocab/unit/DEG
+ x-ogc-unitLang: QUDT
+ long_dd27:
+ title: Longitude (NAD 27)
+ description: >-
+ Longitude in decimal degrees on the older NAD 27 datum, as originally
+ recorded. Positions differ from NAD 83 by roughly 100 metres.
+ x-ogc-unit: https://qudt.org/vocab/unit/DEG
+ x-ogc-unitLang: QUDT
+ elev_gl:
+ title: Ground-level elevation
+ description: Elevation of the ground surface at the well head.
+ elev_kb:
+ title: Kelly bushing elevation
+ description: >-
+ Elevation of the kelly bushing, the point on the drilling rig that
+ drilled depths were measured from. Typically several metres above ground
+ level.
+ elev_unspc:
+ title: Elevation (unspecified datum)
+ description: >-
+ Elevation recorded without a stated reference point, so it may be ground
+ level or a drilling datum.
+ depth_unit:
+ title: Depth unit
+ description: Unit the depths on this record are reported in.
+ temp_unit:
+ title: Temperature unit
+ description: Unit the temperatures on this record are reported in.
+
+locations:
+ nma_pk_location:
+ title: Legacy NM_Aquifer location key
+ description: >-
+ Primary key of this site's record in the legacy NM_Aquifer Location
+ table, kept so migrated rows can be traced back to their source.
+ description:
+ title: Site description
+ description: Free-text description of the site.
+ quad_name:
+ title: USGS quadrangle
+ description: Name of the USGS 7.5-minute topographic quadrangle the site falls in.
+ nma_location_notes:
+ title: Location notes
+ description: >-
+ Notes about the site carried over from NM_Aquifer, typically covering
+ access and how to find it on the ground.
+ nma_coordinate_notes:
+ title: Coordinate notes
+ description: >-
+ Notes on how the coordinates were obtained -- GPS, digitised from a map,
+ or derived from a legal description.
+ nma_data_reliability:
+ title: Data reliability
+ description: >-
+ Legacy rating of how much confidence to place in the site's recorded
+ position.
+ nma_date_created:
+ title: Legacy record created
+ description: Date the site record was created in NM_Aquifer.
+ nma_site_date:
+ title: Site date
+ description: Date associated with the site itself in NM_Aquifer, where one was recorded.
+
+project_areas:
+ name:
+ title: Project name
+ description: Name of the Bureau project the area belongs to.
+ description:
+ title: Project description
+ description: Free-text description of the project.
+ group_type:
+ title: Group type
+ description: >-
+ Kind of grouping the record represents, such as a project or a
+ geographic area.
+
+water_well_summary:
+ elevation_method:
+ title: Elevation method
+ description: >-
+ How the ground-surface elevation was determined, such as GPS survey or
+ read from a digital elevation model. Governs how much precision the
+ elevation deserves.
+ formation_zone:
+ title: Formation zone
+ description: Geologic formation the well draws from, as recorded for the well.
+ total_water_levels:
+ title: Water-level measurement count
+ description: >-
+ Number of manual groundwater-level measurements behind this row's
+ statistics. Small counts make the range and trend unreliable.
+ last_water_level:
+ title: Latest water level
+ description: >-
+ Most recent groundwater-level measurement, as a depth below ground
+ surface. Reported in the units of the source reading, which is feet for
+ almost the whole record; unlike water_elevation_wells this layer does
+ not convert metric readings.
+ last_water_level_datetime:
+ title: Latest measurement time
+ description: Date and time of the most recent groundwater-level measurement.
+ min_water_level:
+ title: Shallowest water level
+ description: >-
+ Smallest depth below ground surface on record -- the high-water mark,
+ since a smaller depth means water nearer the surface.
+ max_water_level:
+ title: Deepest water level
+ description: >-
+ Largest depth below ground surface on record -- the low-water mark,
+ since a larger depth means water further down.
+ water_level_trend_ft_per_year:
+ title: Water-level trend
+ description: >-
+ Slope of a straight line fitted through the well's depth-to-water
+ measurements over time, in feet per year. Positive means depth is
+ increasing, so the water table is falling; negative means it is rising.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT-PER-YR
+ x-ogc-unitLang: QUDT
+
+actively_monitored_wells:
+ elevation_method:
+ title: Elevation method
+ description: >-
+ How the ground-surface elevation was determined, such as GPS survey or
+ read from a digital elevation model.
+ formation_zone:
+ title: Formation zone
+ description: Geologic formation the well draws from, as recorded for the well.
+ total_water_levels:
+ title: Water-level measurement count
+ description: Number of manual groundwater-level measurements on record for the well.
+ last_water_level:
+ title: Latest water level
+ description: >-
+ Most recent groundwater-level measurement, as a depth below ground
+ surface, in the units of the source reading.
+ last_water_level_datetime:
+ title: Latest measurement time
+ description: Date and time of the most recent groundwater-level measurement.
+ min_water_level:
+ title: Shallowest water level
+ description: Smallest depth below ground surface on record for the well.
+ max_water_level:
+ title: Deepest water level
+ description: Largest depth below ground surface on record for the well.
+ water_level_trend_ft_per_year:
+ title: Water-level trend
+ description: >-
+ Slope of a straight line fitted through the well's depth-to-water
+ measurements over time, in feet per year. Positive means the water table
+ is falling.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT-PER-YR
+ x-ogc-unitLang: QUDT
+ group_id:
+ title: Network ID
+ description: Identifier of the monitoring network the well belongs to.
+ group_name:
+ title: Network name
+ description: >-
+ Name of the monitoring network the well belongs to. Always the Water
+ Level Network in this collection.
+ group_type:
+ title: Network type
+ description: Kind of grouping the network record represents.
+
+depth_to_water_trend_wells:
+ record_count:
+ title: Measurement count
+ description: >-
+ Number of groundwater-level measurements the trend was fitted to. Below
+ 10 measurements -- or below 4 spanning less than two years -- the trend
+ is reported as not enough data.
+ first_observation_datetime:
+ title: First measurement time
+ description: Date and time of the earliest measurement used in the fit.
+ last_observation_datetime:
+ title: Latest measurement time
+ description: Date and time of the most recent measurement used in the fit.
+ span_years:
+ title: Record span
+ description: >-
+ Years between the first and last measurement used in the fit. A steep
+ slope over a short span is weak evidence of a real trend.
+ x-ogc-unit: https://qudt.org/vocab/unit/YR
+ x-ogc-unitLang: QUDT
+ slope_ft_per_year:
+ title: Trend slope
+ description: >-
+ Slope of a straight line fitted through depth to water below ground
+ surface over time, in feet per year. Positive means depth is increasing,
+ so the water table is falling.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT-PER-YR
+ x-ogc-unitLang: QUDT
+ trend_category:
+ title: Trend category
+ description: >-
+ Plain-language reading of the slope: increasing (water table falling
+ faster than 0.25 ft/yr), decreasing (rising faster than 0.25 ft/yr),
+ stable, or not enough data.
+
+water_elevation_wells:
+ observation_id:
+ title: Measurement ID
+ description: Identifier of the groundwater-level measurement this row was calculated from.
+ observation_datetime:
+ title: Measurement time
+ description: Date and time the groundwater level was measured.
+ elevation_m:
+ title: Ground-surface elevation
+ description: >-
+ Surveyed elevation of the ground surface at the well, in metres above
+ the NAVD 88 vertical datum.
+ x-ogc-unit: https://qudt.org/vocab/unit/M
+ x-ogc-unitLang: QUDT
+ depth_to_water_below_ground_surface_ft:
+ title: Depth to water
+ description: >-
+ Distance from ground surface down to the water table at the time of
+ measurement. Metric readings are converted to feet, and a reading with
+ no recorded measuring-point height is treated as taken at ground level.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT
+ x-ogc-unitLang: QUDT
+ water_elevation_ft:
+ title: Water-table elevation
+ description: >-
+ Height of the water table above sea level: ground-surface elevation
+ converted to feet, minus the depth to water. Comparable between wells
+ standing at different ground elevations.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT
+ x-ogc-unitLang: QUDT
+
+latest_depth_to_water_wells:
+ observation_id:
+ title: Measurement ID
+ description: Identifier of the groundwater-level measurement this row reports.
+ observation_datetime:
+ title: Measurement time
+ description: Date and time the groundwater level was measured.
+ depth_to_water_reference:
+ title: Depth to water from reference point
+ description: >-
+ Depth to water as read in the field, measured down from the measuring
+ point rather than from the ground.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT
+ x-ogc-unitLang: QUDT
+ measuring_point_height:
+ title: Measuring-point height
+ description: >-
+ Height of the measuring point -- usually the top of the well casing --
+ above ground surface. Subtracted from the field reading to give a depth
+ below ground surface; a reading with no recorded height is treated as
+ taken at ground level.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT
+ x-ogc-unitLang: QUDT
+ depth_to_water_bgs:
+ title: Depth to water below ground surface
+ description: >-
+ Distance from ground surface down to the water table, after subtracting
+ the measuring-point height from the field reading.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT
+ x-ogc-unitLang: QUDT
+
+latest_tds_wells:
+ major_chemistry_id:
+ title: Analysis ID
+ description: Identifier of the laboratory result this row reports.
+ latest_tds_observation_date:
+ title: Analysis date
+ description: >-
+ Date the reported TDS result was analysed, or the date the sample was
+ collected where no analysis date was recorded.
+ latest_tds_value:
+ title: Total dissolved solids
+ description: >-
+ Most recent measured concentration of dissolved mineral matter in the
+ water. Higher values mean saltier water; drinking-water guidance sits
+ around 500 mg/L.
+ latest_tds_units:
+ title: TDS units
+ description: >-
+ Units the TDS value is reported in, as the laboratory recorded them --
+ usually milligrams per litre.
+
+avg_tds_wells:
+ tds_observation_count:
+ title: Analysis count
+ description: >-
+ Number of TDS results the average was taken over. Across the catalog
+ this averages about 1.9, so most rows average one or two samples.
+ avg_tds_value:
+ title: Average total dissolved solids
+ description: >-
+ Arithmetic mean of every TDS result on record for the well, without
+ weighting by date. Read alongside the analysis count before relying on
+ it.
+ first_tds_observation_date:
+ title: First analysis date
+ description: Date of the earliest TDS result included in the average.
+ last_tds_observation_date:
+ title: Latest analysis date
+ description: Date of the most recent TDS result included in the average.
+
+geothermal_wells_bht:
+ bht_count:
+ title: Reading count
+ description: Number of bottom-hole temperature readings recorded for the well.
+ max_bht:
+ title: Highest bottom-hole temperature (as recorded)
+ description: >-
+ Highest bottom-hole temperature on record for the well, in the units the
+ source recorded it in. Use max_bht_c for a comparable value.
+ min_bht:
+ title: Lowest bottom-hole temperature (as recorded)
+ description: >-
+ Lowest bottom-hole temperature on record for the well, in the units the
+ source recorded it in. Use min_bht_c for a comparable value.
+ max_bht_c:
+ title: Highest bottom-hole temperature
+ description: >-
+ Highest bottom-hole temperature on record for the well, converted to
+ degrees Celsius.
+ x-ogc-unit: https://qudt.org/vocab/unit/DEG_C
+ x-ogc-unitLang: QUDT
+ min_bht_c:
+ title: Lowest bottom-hole temperature
+ description: >-
+ Lowest bottom-hole temperature on record for the well, converted to
+ degrees Celsius.
+ x-ogc-unit: https://qudt.org/vocab/unit/DEG_C
+ x-ogc-unitLang: QUDT
+ max_bht_depth:
+ title: Deepest reading depth
+ description: Depth of the deepest bottom-hole temperature reading for the well.
+ temp_unit:
+ title: Temperature unit
+ description: >-
+ Unit the converted temperatures are reported in. Always Celsius; see
+ temp_unit_source for what the readings arrived as.
+ temp_unit_source:
+ title: Source temperature units
+ description: >-
+ Units the underlying readings were recorded in, comma-separated where
+ the well's readings did not agree.
+ temp_unit_mixed:
+ title: Mixed source units
+ description: >-
+ True when the well's readings arrived in more than one temperature unit,
+ which is a sign the source record needs review.
+ unconvertible_count:
+ title: Unconvertible reading count
+ description: >-
+ Number of readings that could not be converted to Celsius because their
+ unit was missing or unrecognised. These are excluded from the minimum
+ and maximum.
+
+geothermal_wells_temperature_profile:
+ reading_count:
+ title: Reading count
+ description: Number of temperature-versus-depth readings logged in the well.
+ min_depth:
+ title: Shallowest reading depth
+ description: Depth of the shallowest temperature reading in the profile.
+ max_depth:
+ title: Deepest reading depth
+ description: Depth of the deepest temperature reading in the profile.
+ min_temp:
+ title: Lowest temperature (as recorded)
+ description: >-
+ Lowest temperature in the profile, in the units the source recorded it
+ in. Use min_temp_c for a comparable value.
+ max_temp:
+ title: Highest temperature (as recorded)
+ description: >-
+ Highest temperature in the profile, in the units the source recorded it
+ in. Use max_temp_c for a comparable value.
+ min_temp_c:
+ title: Lowest temperature
+ description: Lowest temperature in the profile, converted to degrees Celsius.
+ x-ogc-unit: https://qudt.org/vocab/unit/DEG_C
+ x-ogc-unitLang: QUDT
+ max_temp_c:
+ title: Highest temperature
+ description: Highest temperature in the profile, converted to degrees Celsius.
+ x-ogc-unit: https://qudt.org/vocab/unit/DEG_C
+ x-ogc-unitLang: QUDT
+ temp_unit:
+ title: Temperature unit
+ description: >-
+ Unit the converted temperatures are reported in. Always Celsius; see
+ temp_unit_source for what the readings arrived as.
+ temp_unit_source:
+ title: Source temperature units
+ description: >-
+ Units the underlying readings were recorded in, comma-separated where
+ the well's readings did not agree.
+ temp_unit_mixed:
+ title: Mixed source units
+ description: >-
+ True when the well's readings arrived in more than one temperature unit,
+ which is a sign the source record needs review.
+ unconvertible_count:
+ title: Unconvertible reading count
+ description: >-
+ Number of readings that could not be converted to Celsius because their
+ unit was missing or unrecognised.
+ series:
+ title: Temperature-depth profile
+ description: >-
+ The whole profile as a list of readings, each carrying a depth, the
+ temperature as recorded, and the temperature converted to Celsius.
+
+bht_measurements:
+ operator:
+ title: Operator
+ description: Company operating the well when the record was made.
+ well_type:
+ title: Well type
+ description: Purpose the well was drilled for, such as oil, gas, or observation.
+ well_tvd:
+ title: True vertical depth
+ description: >-
+ Vertical depth of the well, which is shorter than the drilled length for
+ a deviated hole.
+ completion_date:
+ title: Completion date
+ description: Date the well was finished.
+ current_status:
+ title: Current status
+ description: Latest recorded status of the well, such as producing or plugged.
+ cuttings:
+ title: Cuttings held
+ description: >-
+ Whether rock cuttings from the well are held in the Bureau's subsurface
+ library.
+ core_exists:
+ title: Core held
+ description: Whether a rock core from the well is held in the Bureau's subsurface library.
+ bht_depth:
+ title: Reading depth
+ description: Depth the bottom-hole temperature was measured at.
+ bht:
+ title: Bottom-hole temperature
+ description: >-
+ Temperature measured at the bottom of the hole, in the units recorded by
+ the source.
+ hours_since_circulation:
+ title: Hours since circulation
+ description: >-
+ Time between the last circulation of drilling fluid and the reading.
+ Drilling fluid cools the rock, so a reading taken soon after circulation
+ is too low; this figure decides whether it can be corrected.
+ x-ogc-unit: https://qudt.org/vocab/unit/HR
+ x-ogc-unitLang: QUDT
+ date_measured:
+ title: Measurement date
+ description: Date the temperature was measured.
+
+temp_depth_measurements:
+ sample_fm:
+ title: Formation
+ description: Geologic formation at the depth the reading was taken.
+ loc_acc_val:
+ title: Location accuracy
+ description: Recorded accuracy of the well's coordinates.
+ entered_by:
+ title: Entered by
+ description: Person who entered the record into the legacy NM_Wells database.
+ depth:
+ title: Reading depth
+ description: Depth below the reference datum the temperature was measured at.
+ temp:
+ title: Temperature
+ description: Temperature measured at this depth, in the units recorded by the source.
+ sample_date:
+ title: Measurement date
+ description: Date the temperature was measured.
+
+heat_flow:
+ elevation_m:
+ title: Elevation
+ description: Well elevation converted to metres.
+ x-ogc-unit: https://qudt.org/vocab/unit/M
+ x-ogc-unitLang: QUDT
+ depth_units:
+ title: Depth units
+ description: Units the depths on this record were originally reported in.
+ total_depth_m:
+ title: Total depth (metres)
+ description: Total drilled depth of the well converted to metres.
+ x-ogc-unit: https://qudt.org/vocab/unit/M
+ x-ogc-unitLang: QUDT
+ from_depth:
+ title: Interval top
+ description: Depth to the top of the interval the determination covers.
+ to_depth:
+ title: Interval base
+ description: Depth to the bottom of the interval the determination covers.
+ therml_cond:
+ title: Thermal conductivity (as published)
+ description: >-
+ How readily the rock conducts heat, in the units it was published in.
+ Use tc_si for a comparable value.
+ tcond_range:
+ title: Thermal conductivity range
+ description: Published spread of conductivity values for the interval.
+ tcond_error:
+ title: Thermal conductivity error
+ description: Published uncertainty on the conductivity value.
+ tcond_unit:
+ title: Thermal conductivity unit
+ description: >-
+ Unit the published conductivity is in. TCU denotes the older thermal
+ conductivity unit, mcal/cm-s-degC.
+ tc_si:
+ title: Thermal conductivity
+ description: >-
+ Thermal conductivity converted to SI units, watts per metre-kelvin.
+ Typical rock sits between 1 and 5.
+ x-ogc-unit: https://qudt.org/vocab/unit/W-PER-M-K
+ x-ogc-unitLang: QUDT
+ sample_type:
+ title: Sample type
+ description: What the conductivity was measured on, such as core or cuttings.
+ num_samples:
+ title: Sample count
+ description: Number of samples the conductivity value was measured from.
+ therml_grad:
+ title: Thermal gradient
+ description: >-
+ How fast temperature rises with depth over the interval, in the units it
+ was published in. Continental crust averages roughly 25 degrees Celsius
+ per kilometre.
+ tgrad_range:
+ title: Thermal gradient range
+ description: Published spread of gradient values for the interval.
+ tg_error:
+ title: Thermal gradient error
+ description: Published uncertainty on the gradient value.
+ grad_unit:
+ title: Thermal gradient unit
+ description: Unit the published gradient is in.
+ heat_flow:
+ title: Heat flow (as published)
+ description: >-
+ Rate at which heat escapes through the ground over this interval, in the
+ units it was published in. Use heat_flow_si for a comparable value.
+ ht_flow_unit:
+ title: Heat flow unit
+ description: >-
+ Unit the published heat flow is in. HFU denotes the older heat flow
+ unit, equal to 41.84 milliwatts per square metre.
+ heat_flow_si:
+ title: Heat flow
+ description: >-
+ Heat flow converted to SI units, milliwatts per square metre. Continental
+ averages sit near 65; values well above that mark geothermal interest.
+ x-ogc-unit: https://qudt.org/vocab/unit/MilliW-PER-M2
+ x-ogc-unitLang: QUDT
+ ht_flow_est:
+ title: Estimated heat flow (as published)
+ description: >-
+ Heat flow the author estimated rather than measured, in the units it was
+ published in.
+ ht_flow_est_si:
+ title: Estimated heat flow
+ description: >-
+ Author-estimated heat flow converted to milliwatts per square metre.
+ x-ogc-unit: https://qudt.org/vocab/unit/MilliW-PER-M2
+ x-ogc-unitLang: QUDT
+ quality:
+ title: Quality rating
+ description: >-
+ The publication's own assessment of how much confidence the
+ determination deserves.
+ first_auth:
+ title: First author
+ description: First author of the publication the determination came from.
+ pub_year:
+ title: Publication year
+ description: Year the determination was published.
+ title:
+ title: Publication title
+ description: Title of the publication the determination came from.
+ journal:
+ title: Journal
+ description: Journal or report series the determination was published in.
+ volume:
+ title: Volume
+ description: Volume of the journal or report series.
+ page_no:
+ title: Pages
+ description: Page range of the publication.
+
+dst:
+ dst_name:
+ title: Test name
+ description: Name recorded for the drill stem test.
+ dst_operator:
+ title: Testing contractor
+ description: Company that ran the drill stem test.
+ dst_number:
+ title: Test number
+ description: Sequence number of this test within the well.
+ dst_date:
+ title: Test date
+ description: Date the drill stem test was run.
+ from_depth:
+ title: Interval top
+ description: Depth to the top of the tested interval.
+ to_depth:
+ title: Interval base
+ description: Depth to the bottom of the tested interval.
+ target_fm:
+ title: Target formation
+ description: Geologic formation the test was aimed at.
+ packer_from:
+ title: Upper packer depth
+ description: >-
+ Depth of the upper packer, the seal that isolates the tested interval
+ from the rest of the hole.
+ packer_to:
+ title: Lower packer depth
+ description: Depth of the lower packer sealing the bottom of the tested interval.
+ srf_choke_sz:
+ title: Surface choke size
+ description: >-
+ Size of the choke at surface, which limits how fast fluid is allowed to
+ flow during the test.
+ bot_choke_sz:
+ title: Bottom choke size
+ description: Size of the choke at the bottom of the string.
+ prs_gage_dpt:
+ title: Pressure gauge depth
+ description: Depth the pressure gauge was set at.
+ pipe_dia:
+ title: Pipe diameter
+ description: Diameter of the drill pipe used for the test.
+ pipe_length:
+ title: Pipe length
+ description: Length of drill pipe run for the test.
+ flow_history:
+ title: Flow history
+ description: >-
+ The operations logged during the test, in order -- opening the tool,
+ flow periods, and shut-in periods.
+ init_flow:
+ title: Initial flow pressure
+ description: Pressure recorded at the start of the first flow period.
+ flw_prs_in_min:
+ title: Initial flow duration
+ description: Length of the first flow period, in minutes.
+ x-ogc-unit: https://qudt.org/vocab/unit/MIN
+ x-ogc-unitLang: QUDT
+ fin_flow:
+ title: Final flow pressure
+ description: Pressure recorded at the end of the last flow period.
+ flw_prs_fin_min:
+ title: Final flow duration
+ description: Length of the last flow period, in minutes.
+ x-ogc-unit: https://qudt.org/vocab/unit/MIN
+ x-ogc-unitLang: QUDT
+ prs_init_clsd_in:
+ title: Initial shut-in pressure
+ description: >-
+ Pressure built up during the first shut-in period, after the tool was
+ closed and fluid stopped flowing.
+ in_sht_in_min:
+ title: Initial shut-in duration
+ description: Length of the first shut-in period, in minutes.
+ x-ogc-unit: https://qudt.org/vocab/unit/MIN
+ x-ogc-unitLang: QUDT
+ fin_shut_in:
+ title: Final shut-in pressure
+ description: >-
+ Pressure built up during the last shut-in period. Usually the closest
+ available estimate of true formation pressure.
+ fn_sht_in_min:
+ title: Final shut-in duration
+ description: Length of the last shut-in period, in minutes.
+ x-ogc-unit: https://qudt.org/vocab/unit/MIN
+ x-ogc-unitLang: QUDT
+ hydrost_prs_in:
+ title: Initial hydrostatic pressure
+ description: >-
+ Pressure of the fluid column in the hole before the test, used as a
+ reference for the flowing pressures.
+ hyd_st_prs_fl:
+ title: Final hydrostatic pressure
+ description: Pressure of the fluid column in the hole at the end of the test.
+ press_units:
+ title: Pressure units
+ description: Units the pressures on this record are reported in.
+ blanked_off:
+ title: Blanked off
+ description: Whether the tested interval was blanked off during the test.
+ fm_temp:
+ title: Formation temperature
+ description: Temperature recorded for the formation during the test.
+
+# ---------------------------------------------------------------------------
+# Chemistry analyte columns below are generated by
+# cli/generate_chemistry_field_descriptions.py and reviewed by hand. Re-run it
+# when the analyte lists in the ogc_* view migrations change.
+# ---------------------------------------------------------------------------
+major_chemistry_results:
+ location_id:
+ title: Location ID
+ description: >-
+ Identifier of the location record the well's coordinates came from.
+
+ analyte_count:
+ title: Analyte count
+ description: >-
+ Number of distinct analytes with a value in this row. A low count
+ means the well has only been analysed for part of the suite.
+
+ latest_chemistry_date:
+ title: Latest analysis date
+ description: >-
+ Date of the most recent result in this row. Analytes are carried
+ forward independently, so an individual value may be older than this
+ date.
+
+ tds:
+ title: Total dissolved solids
+ description: >-
+ Total mass of dissolved mineral matter in the water -- in plain
+ terms, how salty it is. Drinking-water guidance sits around 500
+ mg/L.
+
+ tds_units:
+ title: Total dissolved solids units
+ description: >-
+ Units the total dissolved solids value is reported in, as the
+ laboratory recorded them.
+
+ calcium:
+ title: Calcium
+ description: >-
+ Dissolved calcium concentration in the most recent sample analysed
+ for it.
+
+ calcium_units:
+ title: Calcium units
+ description: >-
+ Units the calcium value is reported in, as the laboratory recorded
+ them.
+
+ calcium_total:
+ title: Calcium (total)
+ description: >-
+ Total calcium concentration -- the unfiltered determination, which
+ counts calcium bound to suspended particles as well as the dissolved
+ fraction.
+
+ calcium_total_units:
+ title: Calcium (total) units
+ description: >-
+ Units the calcium (total) value is reported in, as the laboratory
+ recorded them.
+
+ magnesium:
+ title: Magnesium
+ description: >-
+ Dissolved magnesium concentration in the most recent sample analysed
+ for it.
+
+ magnesium_units:
+ title: Magnesium units
+ description: >-
+ Units the magnesium value is reported in, as the laboratory recorded
+ them.
+
+ magnesium_total:
+ title: Magnesium (total)
+ description: >-
+ Total magnesium concentration -- the unfiltered determination, which
+ counts magnesium bound to suspended particles as well as the
+ dissolved fraction.
+
+ magnesium_total_units:
+ title: Magnesium (total) units
+ description: >-
+ Units the magnesium (total) value is reported in, as the laboratory
+ recorded them.
+
+ sodium:
+ title: Sodium
+ description: >-
+ Dissolved sodium concentration in the most recent sample analysed
+ for it.
+
+ sodium_units:
+ title: Sodium units
+ description: >-
+ Units the sodium value is reported in, as the laboratory recorded
+ them.
+
+ sodium_total:
+ title: Sodium (total)
+ description: >-
+ Total sodium concentration -- the unfiltered determination, which
+ counts sodium bound to suspended particles as well as the dissolved
+ fraction.
+
+ sodium_total_units:
+ title: Sodium (total) units
+ description: >-
+ Units the sodium (total) value is reported in, as the laboratory
+ recorded them.
+
+ potassium:
+ title: Potassium
+ description: >-
+ Dissolved potassium concentration in the most recent sample analysed
+ for it.
+
+ potassium_units:
+ title: Potassium units
+ description: >-
+ Units the potassium value is reported in, as the laboratory recorded
+ them.
+
+ potassium_total:
+ title: Potassium (total)
+ description: >-
+ Total potassium concentration -- the unfiltered determination, which
+ counts potassium bound to suspended particles as well as the
+ dissolved fraction.
+
+ potassium_total_units:
+ title: Potassium (total) units
+ description: >-
+ Units the potassium (total) value is reported in, as the laboratory
+ recorded them.
+
+ sodium_plus_potassium:
+ title: Sodium plus potassium
+ description: >-
+ Combined sodium and potassium concentration, reported together where
+ the laboratory did not separate them.
+
+ sodium_plus_potassium_units:
+ title: Sodium plus potassium units
+ description: >-
+ Units the sodium plus potassium value is reported in, as the
+ laboratory recorded them.
+
+ bicarbonate:
+ title: Bicarbonate
+ description: >-
+ Dissolved bicarbonate concentration in the most recent sample
+ analysed for it.
+
+ bicarbonate_units:
+ title: Bicarbonate units
+ description: >-
+ Units the bicarbonate value is reported in, as the laboratory
+ recorded them.
+
+ carbonate:
+ title: Carbonate
+ description: >-
+ Dissolved carbonate concentration in the most recent sample analysed
+ for it.
+
+ carbonate_units:
+ title: Carbonate units
+ description: >-
+ Units the carbonate value is reported in, as the laboratory recorded
+ them.
+
+ sulfate:
+ title: Sulfate
+ description: >-
+ Dissolved sulfate concentration in the most recent sample analysed
+ for it.
+
+ sulfate_units:
+ title: Sulfate units
+ description: >-
+ Units the sulfate value is reported in, as the laboratory recorded
+ them.
+
+ chloride:
+ title: Chloride
+ description: >-
+ Dissolved chloride concentration in the most recent sample analysed
+ for it.
+
+ chloride_units:
+ title: Chloride units
+ description: >-
+ Units the chloride value is reported in, as the laboratory recorded
+ them.
+
+ ion_balance:
+ title: Ion balance
+ description: >-
+ Percentage difference between the total positive and total negative
+ charge in the analysis. Charge must balance in reality, so a figure
+ far from zero means the analysis is incomplete or in error.
+
+ ion_balance_units:
+ title: Ion balance units
+ description: >-
+ Units the ion balance value is reported in, as the laboratory
+ recorded them.
+
+ total_anions:
+ title: Total anions
+ description: >-
+ Sum of the negatively charged dissolved constituents in the
+ analysis.
+
+ total_anions_units:
+ title: Total anions units
+ description: >-
+ Units the total anions value is reported in, as the laboratory
+ recorded them.
+
+ total_cations:
+ title: Total cations
+ description: >-
+ Sum of the positively charged dissolved constituents in the
+ analysis.
+
+ total_cations_units:
+ title: Total cations units
+ description: >-
+ Units the total cations value is reported in, as the laboratory
+ recorded them.
+
+ alkalinity:
+ title: Alkalinity
+ description: >-
+ The water's capacity to neutralise acid, reported as an equivalent
+ mass of calcium carbonate. Mostly supplied by bicarbonate and
+ carbonate.
+
+ alkalinity_units:
+ title: Alkalinity units
+ description: >-
+ Units the alkalinity value is reported in, as the laboratory
+ recorded them.
+
+ hardness:
+ title: Hardness
+ description: >-
+ Combined calcium and magnesium content, reported as an equivalent
+ mass of calcium carbonate. What determines whether water is 'hard'.
+
+ hardness_units:
+ title: Hardness units
+ description: >-
+ Units the hardness value is reported in, as the laboratory recorded
+ them.
+
+ specific_conductance:
+ title: Specific conductance
+ description: >-
+ How well the water conducts electricity, which rises with dissolved
+ mineral content. Used as a fast field proxy for total dissolved
+ solids.
+
+ specific_conductance_units:
+ title: Specific conductance units
+ description: >-
+ Units the specific conductance value is reported in, as the
+ laboratory recorded them.
+
+ ph:
+ title: pH
+ description: >-
+ Acidity of the water on the 0-14 scale, where 7 is neutral.
+ Unitless. Most New Mexico groundwater falls between 7 and 8.5.
+
+ ph_units:
+ title: pH units
+ description: >-
+ Units the ph value is reported in, as the laboratory recorded them.
+
+ nitrate:
+ title: Nitrate
+ description: >-
+ Dissolved nitrate concentration, usually from fertiliser, septic
+ systems, or livestock. The drinking-water limit is 10 mg/L as
+ nitrogen.
+
+ nitrate_units:
+ title: Nitrate units
+ description: >-
+ Units the nitrate value is reported in, as the laboratory recorded
+ them.
+
+ fluoride:
+ title: Fluoride
+ description: >-
+ Dissolved fluoride concentration. Beneficial in small amounts; the
+ drinking-water limit is 4 mg/L.
+
+ fluoride_units:
+ title: Fluoride units
+ description: >-
+ Units the fluoride value is reported in, as the laboratory recorded
+ them.
+
+ silica:
+ title: Silica
+ description: >-
+ Dissolved silica concentration, weathered out of silicate rock.
+ Useful for estimating the temperature water last equilibrated at.
+
+ silica_units:
+ title: Silica units
+ description: >-
+ Units the silica value is reported in, as the laboratory recorded
+ them.
+
+minor_chemistry_wells:
+ location_id:
+ title: Location ID
+ description: >-
+ Identifier of the location record the well's coordinates came from.
+
+ analyte_count:
+ title: Analyte count
+ description: >-
+ Number of distinct analytes with a value in this row. A low count
+ means the well has only been analysed for part of the suite.
+
+ latest_chemistry_date:
+ title: Latest analysis date
+ description: >-
+ Date of the most recent result in this row. Analytes are carried
+ forward independently, so an individual value may be older than this
+ date.
+
+ h2r:
+ title: Deuterium ratio
+ description: >-
+ Ratio of heavy to ordinary hydrogen in the water, reported as
+ per-mil difference from ocean water. Fingerprints where the water
+ fell as precipitation.
+
+ h2r_units:
+ title: Deuterium ratio units
+ description: >-
+ Units the deuterium ratio value is reported in, as the laboratory
+ recorded them.
+
+ o18r:
+ title: Oxygen-18 ratio
+ description: >-
+ Ratio of heavy to ordinary oxygen in the water, reported as per-mil
+ difference from ocean water. Read with the deuterium ratio to trace
+ the water's origin and evaporation history.
+
+ o18r_units:
+ title: Oxygen-18 ratio units
+ description: >-
+ Units the oxygen-18 ratio value is reported in, as the laboratory
+ recorded them.
+
+ c13r:
+ title: Carbon-13 ratio
+ description: >-
+ Ratio of carbon-13 to carbon-12 in the water's dissolved carbon,
+ reported as per-mil difference from a standard. Helps identify where
+ the carbon came from, which is needed to correct a carbon-14 age.
+
+ c13r_units:
+ title: Carbon-13 ratio units
+ description: >-
+ Units the carbon-13 ratio value is reported in, as the laboratory
+ recorded them.
+
+ c14:
+ title: Carbon-14
+ description: >-
+ Carbon-14 remaining in the water's dissolved carbon, as a percentage
+ of the modern atmospheric level. The basis for dating groundwater up
+ to roughly 40,000 years old.
+
+ c14_units:
+ title: Carbon-14 units
+ description: >-
+ Units the carbon-14 value is reported in, as the laboratory recorded
+ them.
+
+ c14_years:
+ title: Carbon-14 age
+ description: >-
+ Apparent age of the water in years, calculated from its carbon-14
+ content. Uncorrected for carbon picked up from rock, so treat it as
+ an upper bound.
+
+ c14_years_units:
+ title: Carbon-14 age units
+ description: >-
+ Units the carbon-14 age value is reported in, as the laboratory
+ recorded them.
+
+ fluoride:
+ title: Fluoride
+ description: >-
+ Dissolved fluoride concentration. Beneficial in small amounts; the
+ drinking-water limit is 4 mg/L.
+
+ fluoride_units:
+ title: Fluoride units
+ description: >-
+ Units the fluoride value is reported in, as the laboratory recorded
+ them.
+
+ barium:
+ title: Barium
+ description: >-
+ Dissolved barium concentration in the most recent sample analysed
+ for it.
+
+ barium_units:
+ title: Barium units
+ description: >-
+ Units the barium value is reported in, as the laboratory recorded
+ them.
+
+ barium_total:
+ title: Barium (total)
+ description: >-
+ Total barium concentration -- the unfiltered determination, which
+ counts barium bound to suspended particles as well as the dissolved
+ fraction.
+
+ barium_total_units:
+ title: Barium (total) units
+ description: >-
+ Units the barium (total) value is reported in, as the laboratory
+ recorded them.
+
+ copper:
+ title: Copper
+ description: >-
+ Dissolved copper concentration in the most recent sample analysed
+ for it.
+
+ copper_units:
+ title: Copper units
+ description: >-
+ Units the copper value is reported in, as the laboratory recorded
+ them.
+
+ copper_total:
+ title: Copper (total)
+ description: >-
+ Total copper concentration -- the unfiltered determination, which
+ counts copper bound to suspended particles as well as the dissolved
+ fraction.
+
+ copper_total_units:
+ title: Copper (total) units
+ description: >-
+ Units the copper (total) value is reported in, as the laboratory
+ recorded them.
+
+ zinc:
+ title: Zinc
+ description: >-
+ Dissolved zinc concentration in the most recent sample analysed for
+ it.
+
+ zinc_units:
+ title: Zinc units
+ description: >-
+ Units the zinc value is reported in, as the laboratory recorded
+ them.
+
+ zinc_total:
+ title: Zinc (total)
+ description: >-
+ Total zinc concentration -- the unfiltered determination, which
+ counts zinc bound to suspended particles as well as the dissolved
+ fraction.
+
+ zinc_total_units:
+ title: Zinc (total) units
+ description: >-
+ Units the zinc (total) value is reported in, as the laboratory
+ recorded them.
+
+ molybdenum:
+ title: Molybdenum
+ description: >-
+ Dissolved molybdenum concentration in the most recent sample
+ analysed for it.
+
+ molybdenum_units:
+ title: Molybdenum units
+ description: >-
+ Units the molybdenum value is reported in, as the laboratory
+ recorded them.
+
+ molybdenum_total:
+ title: Molybdenum (total)
+ description: >-
+ Total molybdenum concentration -- the unfiltered determination,
+ which counts molybdenum bound to suspended particles as well as the
+ dissolved fraction.
+
+ molybdenum_total_units:
+ title: Molybdenum (total) units
+ description: >-
+ Units the molybdenum (total) value is reported in, as the laboratory
+ recorded them.
+
+ silica:
+ title: Silica
+ description: >-
+ Dissolved silica concentration, weathered out of silicate rock.
+ Useful for estimating the temperature water last equilibrated at.
+
+ silica_units:
+ title: Silica units
+ description: >-
+ Units the silica value is reported in, as the laboratory recorded
+ them.
+
+ silicon:
+ title: Silicon
+ description: >-
+ Dissolved silicon concentration in the most recent sample analysed
+ for it.
+
+ silicon_units:
+ title: Silicon units
+ description: >-
+ Units the silicon value is reported in, as the laboratory recorded
+ them.
+
+ silicon_total:
+ title: Silicon (total)
+ description: >-
+ Total silicon concentration -- the unfiltered determination, which
+ counts silicon bound to suspended particles as well as the dissolved
+ fraction.
+
+ silicon_total_units:
+ title: Silicon (total) units
+ description: >-
+ Units the silicon (total) value is reported in, as the laboratory
+ recorded them.
+
+ manganese:
+ title: Manganese
+ description: >-
+ Dissolved manganese concentration in the most recent sample analysed
+ for it.
+
+ manganese_units:
+ title: Manganese units
+ description: >-
+ Units the manganese value is reported in, as the laboratory recorded
+ them.
+
+ manganese_total:
+ title: Manganese (total)
+ description: >-
+ Total manganese concentration -- the unfiltered determination, which
+ counts manganese bound to suspended particles as well as the
+ dissolved fraction.
+
+ manganese_total_units:
+ title: Manganese (total) units
+ description: >-
+ Units the manganese (total) value is reported in, as the laboratory
+ recorded them.
+
+ iron:
+ title: Iron
+ description: >-
+ Dissolved iron concentration in the most recent sample analysed for
+ it.
+
+ iron_units:
+ title: Iron units
+ description: >-
+ Units the iron value is reported in, as the laboratory recorded
+ them.
+
+ iron_total:
+ title: Iron (total)
+ description: >-
+ Total iron concentration -- the unfiltered determination, which
+ counts iron bound to suspended particles as well as the dissolved
+ fraction.
+
+ iron_total_units:
+ title: Iron (total) units
+ description: >-
+ Units the iron (total) value is reported in, as the laboratory
+ recorded them.
+
+ strontium:
+ title: Strontium
+ description: >-
+ Dissolved strontium concentration in the most recent sample analysed
+ for it.
+
+ strontium_units:
+ title: Strontium units
+ description: >-
+ Units the strontium value is reported in, as the laboratory recorded
+ them.
+
+ strontium_total:
+ title: Strontium (total)
+ description: >-
+ Total strontium concentration -- the unfiltered determination, which
+ counts strontium bound to suspended particles as well as the
+ dissolved fraction.
+
+ strontium_total_units:
+ title: Strontium (total) units
+ description: >-
+ Units the strontium (total) value is reported in, as the laboratory
+ recorded them.
+
+ chromium:
+ title: Chromium
+ description: >-
+ Dissolved chromium concentration in the most recent sample analysed
+ for it.
+
+ chromium_units:
+ title: Chromium units
+ description: >-
+ Units the chromium value is reported in, as the laboratory recorded
+ them.
+
+ chromium_total:
+ title: Chromium (total)
+ description: >-
+ Total chromium concentration -- the unfiltered determination, which
+ counts chromium bound to suspended particles as well as the
+ dissolved fraction.
+
+ chromium_total_units:
+ title: Chromium (total) units
+ description: >-
+ Units the chromium (total) value is reported in, as the laboratory
+ recorded them.
+
+ boron:
+ title: Boron
+ description: >-
+ Dissolved boron concentration in the most recent sample analysed for
+ it.
+
+ boron_units:
+ title: Boron units
+ description: >-
+ Units the boron value is reported in, as the laboratory recorded
+ them.
+
+ boron_total:
+ title: Boron (total)
+ description: >-
+ Total boron concentration -- the unfiltered determination, which
+ counts boron bound to suspended particles as well as the dissolved
+ fraction.
+
+ boron_total_units:
+ title: Boron (total) units
+ description: >-
+ Units the boron (total) value is reported in, as the laboratory
+ recorded them.
+
+ uranium:
+ title: Uranium
+ description: >-
+ Dissolved uranium concentration. Naturally present near
+ uranium-bearing rock and regulated in drinking water at 0.030 mg/L.
+
+ uranium_units:
+ title: Uranium units
+ description: >-
+ Units the uranium value is reported in, as the laboratory recorded
+ them.
+
+ uranium_total:
+ title: Uranium (total)
+ description: >-
+ Total uranium concentration -- the unfiltered determination, which
+ counts uranium bound to suspended particles as well as the dissolved
+ fraction.
+
+ uranium_total_units:
+ title: Uranium (total) units
+ description: >-
+ Units the uranium (total) value is reported in, as the laboratory
+ recorded them.
+
+ lithium:
+ title: Lithium
+ description: >-
+ Dissolved lithium concentration in the most recent sample analysed
+ for it.
+
+ lithium_units:
+ title: Lithium units
+ description: >-
+ Units the lithium value is reported in, as the laboratory recorded
+ them.
+
+ lithium_total:
+ title: Lithium (total)
+ description: >-
+ Total lithium concentration -- the unfiltered determination, which
+ counts lithium bound to suspended particles as well as the dissolved
+ fraction.
+
+ lithium_total_units:
+ title: Lithium (total) units
+ description: >-
+ Units the lithium (total) value is reported in, as the laboratory
+ recorded them.
+
+ silver:
+ title: Silver
+ description: >-
+ Dissolved silver concentration in the most recent sample analysed
+ for it.
+
+ silver_units:
+ title: Silver units
+ description: >-
+ Units the silver value is reported in, as the laboratory recorded
+ them.
+
+ silver_total:
+ title: Silver (total)
+ description: >-
+ Total silver concentration -- the unfiltered determination, which
+ counts silver bound to suspended particles as well as the dissolved
+ fraction.
+
+ silver_total_units:
+ title: Silver (total) units
+ description: >-
+ Units the silver (total) value is reported in, as the laboratory
+ recorded them.
+
+ antimony:
+ title: Antimony
+ description: >-
+ Dissolved antimony concentration in the most recent sample analysed
+ for it.
+
+ antimony_units:
+ title: Antimony units
+ description: >-
+ Units the antimony value is reported in, as the laboratory recorded
+ them.
+
+ antimony_total:
+ title: Antimony (total)
+ description: >-
+ Total antimony concentration -- the unfiltered determination, which
+ counts antimony bound to suspended particles as well as the
+ dissolved fraction.
+
+ antimony_total_units:
+ title: Antimony (total) units
+ description: >-
+ Units the antimony (total) value is reported in, as the laboratory
+ recorded them.
+
+ beryllium:
+ title: Beryllium
+ description: >-
+ Dissolved beryllium concentration in the most recent sample analysed
+ for it.
+
+ beryllium_units:
+ title: Beryllium units
+ description: >-
+ Units the beryllium value is reported in, as the laboratory recorded
+ them.
+
+ beryllium_total:
+ title: Beryllium (total)
+ description: >-
+ Total beryllium concentration -- the unfiltered determination, which
+ counts beryllium bound to suspended particles as well as the
+ dissolved fraction.
+
+ beryllium_total_units:
+ title: Beryllium (total) units
+ description: >-
+ Units the beryllium (total) value is reported in, as the laboratory
+ recorded them.
+
+ lead:
+ title: Lead
+ description: >-
+ Dissolved lead concentration in the most recent sample analysed for
+ it.
+
+ lead_units:
+ title: Lead units
+ description: >-
+ Units the lead value is reported in, as the laboratory recorded
+ them.
+
+ lead_total:
+ title: Lead (total)
+ description: >-
+ Total lead concentration -- the unfiltered determination, which
+ counts lead bound to suspended particles as well as the dissolved
+ fraction.
+
+ lead_total_units:
+ title: Lead (total) units
+ description: >-
+ Units the lead (total) value is reported in, as the laboratory
+ recorded them.
+
+ thallium:
+ title: Thallium
+ description: >-
+ Dissolved thallium concentration in the most recent sample analysed
+ for it.
+
+ thallium_units:
+ title: Thallium units
+ description: >-
+ Units the thallium value is reported in, as the laboratory recorded
+ them.
+
+ thallium_total:
+ title: Thallium (total)
+ description: >-
+ Total thallium concentration -- the unfiltered determination, which
+ counts thallium bound to suspended particles as well as the
+ dissolved fraction.
+
+ thallium_total_units:
+ title: Thallium (total) units
+ description: >-
+ Units the thallium (total) value is reported in, as the laboratory
+ recorded them.
+
+ bromide:
+ title: Bromide
+ description: >-
+ Dissolved bromide concentration. Read against chloride, it
+ distinguishes seawater-derived salinity from dissolved halite.
+
+ bromide_units:
+ title: Bromide units
+ description: >-
+ Units the bromide value is reported in, as the laboratory recorded
+ them.
+
+ selenium:
+ title: Selenium
+ description: >-
+ Dissolved selenium concentration in the most recent sample analysed
+ for it.
+
+ selenium_units:
+ title: Selenium units
+ description: >-
+ Units the selenium value is reported in, as the laboratory recorded
+ them.
+
+ selenium_total:
+ title: Selenium (total)
+ description: >-
+ Total selenium concentration -- the unfiltered determination, which
+ counts selenium bound to suspended particles as well as the
+ dissolved fraction.
+
+ selenium_total_units:
+ title: Selenium (total) units
+ description: >-
+ Units the selenium (total) value is reported in, as the laboratory
+ recorded them.
+
+ vanadium:
+ title: Vanadium
+ description: >-
+ Dissolved vanadium concentration in the most recent sample analysed
+ for it.
+
+ vanadium_units:
+ title: Vanadium units
+ description: >-
+ Units the vanadium value is reported in, as the laboratory recorded
+ them.
+
+ vanadium_total:
+ title: Vanadium (total)
+ description: >-
+ Total vanadium concentration -- the unfiltered determination, which
+ counts vanadium bound to suspended particles as well as the
+ dissolved fraction.
+
+ vanadium_total_units:
+ title: Vanadium (total) units
+ description: >-
+ Units the vanadium (total) value is reported in, as the laboratory
+ recorded them.
+
+ aluminum:
+ title: Aluminum
+ description: >-
+ Dissolved aluminum concentration in the most recent sample analysed
+ for it.
+
+ aluminum_units:
+ title: Aluminum units
+ description: >-
+ Units the aluminum value is reported in, as the laboratory recorded
+ them.
+
+ aluminum_total:
+ title: Aluminum (total)
+ description: >-
+ Total aluminum concentration -- the unfiltered determination, which
+ counts aluminum bound to suspended particles as well as the
+ dissolved fraction.
+
+ aluminum_total_units:
+ title: Aluminum (total) units
+ description: >-
+ Units the aluminum (total) value is reported in, as the laboratory
+ recorded them.
+
+ arsenic:
+ title: Arsenic
+ description: >-
+ Dissolved arsenic concentration. Naturally elevated in parts of New
+ Mexico and regulated in drinking water at 0.010 mg/L.
+
+ arsenic_units:
+ title: Arsenic units
+ description: >-
+ Units the arsenic value is reported in, as the laboratory recorded
+ them.
+
+ arsenic_total:
+ title: Arsenic (total)
+ description: >-
+ Total arsenic concentration -- the unfiltered determination, which
+ counts arsenic bound to suspended particles as well as the dissolved
+ fraction.
+
+ arsenic_total_units:
+ title: Arsenic (total) units
+ description: >-
+ Units the arsenic (total) value is reported in, as the laboratory
+ recorded them.
+
+ nickel:
+ title: Nickel
+ description: >-
+ Dissolved nickel concentration in the most recent sample analysed
+ for it.
+
+ nickel_units:
+ title: Nickel units
+ description: >-
+ Units the nickel value is reported in, as the laboratory recorded
+ them.
+
+ nickel_total:
+ title: Nickel (total)
+ description: >-
+ Total nickel concentration -- the unfiltered determination, which
+ counts nickel bound to suspended particles as well as the dissolved
+ fraction.
+
+ nickel_total_units:
+ title: Nickel (total) units
+ description: >-
+ Units the nickel (total) value is reported in, as the laboratory
+ recorded them.
+
+ cadmium:
+ title: Cadmium
+ description: >-
+ Dissolved cadmium concentration in the most recent sample analysed
+ for it.
+
+ cadmium_units:
+ title: Cadmium units
+ description: >-
+ Units the cadmium value is reported in, as the laboratory recorded
+ them.
+
+ cadmium_total:
+ title: Cadmium (total)
+ description: >-
+ Total cadmium concentration -- the unfiltered determination, which
+ counts cadmium bound to suspended particles as well as the dissolved
+ fraction.
+
+ cadmium_total_units:
+ title: Cadmium (total) units
+ description: >-
+ Units the cadmium (total) value is reported in, as the laboratory
+ recorded them.
+
+ cobalt:
+ title: Cobalt
+ description: >-
+ Dissolved cobalt concentration in the most recent sample analysed
+ for it.
+
+ cobalt_units:
+ title: Cobalt units
+ description: >-
+ Units the cobalt value is reported in, as the laboratory recorded
+ them.
+
+ cobalt_total:
+ title: Cobalt (total)
+ description: >-
+ Total cobalt concentration -- the unfiltered determination, which
+ counts cobalt bound to suspended particles as well as the dissolved
+ fraction.
+
+ cobalt_total_units:
+ title: Cobalt (total) units
+ description: >-
+ Units the cobalt (total) value is reported in, as the laboratory
+ recorded them.
+
+ phosphate:
+ title: Phosphate
+ description: >-
+ Dissolved phosphate concentration in the most recent sample analysed
+ for it.
+
+ phosphate_units:
+ title: Phosphate units
+ description: >-
+ Units the phosphate value is reported in, as the laboratory recorded
+ them.
+
+ nitrite:
+ title: Nitrite
+ description: >-
+ Dissolved nitrite concentration, an intermediate stage in the
+ breakdown of nitrogen compounds.
+
+ nitrite_units:
+ title: Nitrite units
+ description: >-
+ Units the nitrite value is reported in, as the laboratory recorded
+ them.
+
+ nitrate:
+ title: Nitrate
+ description: >-
+ Dissolved nitrate concentration, usually from fertiliser, septic
+ systems, or livestock. The drinking-water limit is 10 mg/L as
+ nitrogen.
+
+ nitrate_units:
+ title: Nitrate units
+ description: >-
+ Units the nitrate value is reported in, as the laboratory recorded
+ them.
+
+ nitrate_as_n:
+ title: Nitrate as nitrogen
+ description: >-
+ Nitrate concentration expressed as the mass of nitrogen alone, which
+ is how the 10 mg/L drinking-water limit is written. Roughly a
+ quarter of the same sample reported as nitrate.
+
+ nitrate_as_n_units:
+ title: Nitrate as nitrogen units
+ description: >-
+ Units the nitrate as nitrogen value is reported in, as the
+ laboratory recorded them.
+
+ thorium:
+ title: Thorium
+ description: >-
+ Dissolved thorium concentration in the most recent sample analysed
+ for it.
+
+ thorium_units:
+ title: Thorium units
+ description: >-
+ Units the thorium value is reported in, as the laboratory recorded
+ them.
+
+ thorium_total:
+ title: Thorium (total)
+ description: >-
+ Total thorium concentration -- the unfiltered determination, which
+ counts thorium bound to suspended particles as well as the dissolved
+ fraction.
+
+ thorium_total_units:
+ title: Thorium (total) units
+ description: >-
+ Units the thorium (total) value is reported in, as the laboratory
+ recorded them.
+
+ tin:
+ title: Tin
+ description: >-
+ Dissolved tin concentration in the most recent sample analysed for
+ it.
+
+ tin_units:
+ title: Tin units
+ description: >-
+ Units the tin value is reported in, as the laboratory recorded them.
+
+ tin_total:
+ title: Tin (total)
+ description: >-
+ Total tin concentration -- the unfiltered determination, which
+ counts tin bound to suspended particles as well as the dissolved
+ fraction.
+
+ tin_total_units:
+ title: Tin (total) units
+ description: >-
+ Units the tin (total) value is reported in, as the laboratory
+ recorded them.
+
+ mercury:
+ title: Mercury
+ description: >-
+ Dissolved mercury concentration in the most recent sample analysed
+ for it.
+
+ mercury_units:
+ title: Mercury units
+ description: >-
+ Units the mercury value is reported in, as the laboratory recorded
+ them.
+
+ mercury_total:
+ title: Mercury (total)
+ description: >-
+ Total mercury concentration -- the unfiltered determination, which
+ counts mercury bound to suspended particles as well as the dissolved
+ fraction.
+
+ mercury_total_units:
+ title: Mercury (total) units
+ description: >-
+ Units the mercury (total) value is reported in, as the laboratory
+ recorded them.
+
+ titanium:
+ title: Titanium
+ description: >-
+ Dissolved titanium concentration in the most recent sample analysed
+ for it.
+
+ titanium_units:
+ title: Titanium units
+ description: >-
+ Units the titanium value is reported in, as the laboratory recorded
+ them.
+
+ titanium_total:
+ title: Titanium (total)
+ description: >-
+ Total titanium concentration -- the unfiltered determination, which
+ counts titanium bound to suspended particles as well as the
+ dissolved fraction.
+
+ titanium_total_units:
+ title: Titanium (total) units
+ description: >-
+ Units the titanium (total) value is reported in, as the laboratory
+ recorded them.
+
diff --git a/core/ogc_field_metadata.py b/core/ogc_field_metadata.py
new file mode 100644
index 000000000..f1f514186
--- /dev/null
+++ b/core/ogc_field_metadata.py
@@ -0,0 +1,154 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Per-field prose for the OGC collections.
+
+Collection-level ``title``/``description``/``keywords`` live in
+``core/pygeoapi.py`` and the two pygeoapi config templates. This module is the
+level below: what an individual column means, and what unit it is in.
+
+The copy lives in ``core/ogc-field-descriptions.yml``, keyed by backing
+relation name with the ``ogc_``/``ogc_internal_`` prefix stripped, so the
+public and internal mounts share one entry per view.
+
+Read ``docs/ogc-field-descriptions.md`` before changing the shape of the YAML
+or upgrading pygeoapi.
+"""
+
+import logging
+from pathlib import Path
+
+import yaml
+
+LOGGER = logging.getLogger(__name__)
+
+# The prefixes _thing_collections_block and _edr_collections_block prepend to a
+# collection id to reach its backing relation. Longest first: "ogc_internal_"
+# also starts with "ogc_".
+TABLE_PREFIXES = ("ogc_internal_", "ogc_")
+
+# Entries carry documentation, not schema. Types and formats stay with the
+# provider's own reflection.
+ALLOWED_KEYS = frozenset(
+ {
+ "title",
+ "description",
+ "x-ogc-unit",
+ "x-ogc-unitLang",
+ "x-ogc-propertySeq",
+ }
+)
+
+DEFAULTS_KEY = "_defaults"
+
+_CACHE = None
+
+
+def _metadata_path() -> Path:
+ return Path(__file__).resolve().parent / "ogc-field-descriptions.yml"
+
+
+def _validate(raw: dict, path: Path) -> dict:
+ if not isinstance(raw, dict):
+ raise ValueError(f"{path} must contain a mapping of table -> fields.")
+
+ for table, fields in raw.items():
+ if not isinstance(fields, dict):
+ raise ValueError(f"{path}: {table} must be a mapping of field -> entry.")
+ for field, entry in fields.items():
+ if not isinstance(entry, dict):
+ raise ValueError(
+ f"{path}: {table}.{field} must be a mapping, got {type(entry).__name__}."
+ )
+ if not entry.get("title"):
+ raise ValueError(f"{path}: {table}.{field} is missing a title.")
+ unknown = set(entry) - ALLOWED_KEYS
+ if unknown:
+ raise ValueError(
+ f"{path}: {table}.{field} has unsupported keys "
+ f"{sorted(unknown)}; allowed keys are {sorted(ALLOWED_KEYS)}."
+ )
+ return raw
+
+
+def load_field_metadata(refresh: bool = False) -> dict:
+ """Return the parsed YAML, read once per process.
+
+ Deliberately free of any database dependency: this is called during
+ OpenAPI generation, which runs before the backing views need to exist.
+ """
+ global _CACHE
+ if _CACHE is None or refresh:
+ path = _metadata_path()
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
+ _CACHE = _validate(raw, path)
+ return _CACHE
+
+
+def strip_table_prefix(table: str) -> str:
+ """Reduce ``ogc_water_wells``/``ogc_internal_water_wells`` to ``water_wells``."""
+ for prefix in TABLE_PREFIXES:
+ if table.startswith(prefix):
+ return table[len(prefix) :]
+ return table
+
+
+def default_title(column_name: str) -> str:
+ """Fallback title for a column with no entry: ``well_depth`` -> ``Well Depth``."""
+ return column_name.replace("_", " ").strip().title()
+
+
+def table_entries(table: str) -> dict:
+ """Documentation entries in force for ``table``, defaults included."""
+ metadata = load_field_metadata()
+ entries = dict(metadata.get(DEFAULTS_KEY, {}))
+ entries.update(metadata.get(strip_table_prefix(table), {}))
+ return entries
+
+
+def describe_fields(table: str, fields: dict) -> dict:
+ """Annotate a provider's reflected ``fields`` with prose from the YAML.
+
+ Returns a new dict of new per-field dicts. That is not tidiness:
+ ``pygeoapi.api.get_collection_schema`` assigns the provider's own field
+ dict into the response and then mutates it in place (pops ``format``,
+ assigns ``x-ogc-role``), so handing out references into the cached YAML
+ would let one request's mutations leak into the next one's.
+ """
+ entries = table_entries(table)
+ described = {}
+ undocumented = []
+
+ for name, field in (fields or {}).items():
+ annotated = dict(field)
+ entry = entries.get(name)
+ if entry:
+ for key, value in entry.items():
+ annotated[key] = value
+ else:
+ annotated.setdefault("title", default_title(name))
+ undocumented.append(name)
+ described[name] = annotated
+
+ if undocumented:
+ # Not fatal: a response with a generated title beats a 500. The drift
+ # guard in tests/test_ogc_field_descriptions.py is what fails the build.
+ LOGGER.warning(
+ "No field description for %s.%s; falling back to a generated title.",
+ table,
+ ", ".join(sorted(undocumented)),
+ )
+
+ return described
diff --git a/tests/test_ogc_field_metadata.py b/tests/test_ogc_field_metadata.py
new file mode 100644
index 000000000..494825e71
--- /dev/null
+++ b/tests/test_ogc_field_metadata.py
@@ -0,0 +1,123 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Unit tests for core/ogc_field_metadata.py. No database, no pygeoapi."""
+
+import pytest
+
+from core.ogc_field_metadata import (
+ ALLOWED_KEYS,
+ default_title,
+ describe_fields,
+ load_field_metadata,
+ strip_table_prefix,
+ table_entries,
+)
+
+
+def test_metadata_file_loads_and_validates():
+ metadata = load_field_metadata()
+
+ assert "_defaults" in metadata
+ for table, fields in metadata.items():
+ for field, entry in fields.items():
+ assert entry["title"], f"{table}.{field} has no title"
+ assert not set(entry) - ALLOWED_KEYS
+
+
+@pytest.mark.parametrize(
+ "table,expected",
+ [
+ ("ogc_water_wells", "water_wells"),
+ ("ogc_internal_water_wells", "water_wells"),
+ ("water_wells", "water_wells"),
+ # "ogc_internal_" has to be stripped before "ogc_", or the internal
+ # tables would look up an "internal_water_wells" block that has no
+ # entries and every field would fall back.
+ ("ogc_internal_other_things", "other_things"),
+ ],
+)
+def test_strip_table_prefix(table, expected):
+ assert strip_table_prefix(table) == expected
+
+
+def test_default_title():
+ assert default_title("depth_to_water_bgs") == "Depth To Water Bgs"
+ assert default_title("id") == "Id"
+
+
+def test_table_entries_merge_defaults_under_the_table_block():
+ entries = table_entries("ogc_water_well_summary")
+
+ # From _defaults.
+ assert entries["id"]["title"] == "Feature ID"
+ # From the table block.
+ assert entries["total_water_levels"]["title"] == "Water-level measurement count"
+
+
+def test_table_entries_prefer_the_table_block_over_defaults():
+ # locations.description is the site description, not any default.
+ assert table_entries("ogc_locations")["description"]["title"] == "Site description"
+ assert table_entries("ogc_project_areas")["description"]["title"] == (
+ "Project description"
+ )
+
+
+def test_describe_fields_annotates_documented_columns():
+ described = describe_fields(
+ "ogc_internal_water_wells",
+ {"well_depth": {"type": "number", "format": None}},
+ )
+
+ field = described["well_depth"]
+ assert field["type"] == "number"
+ assert field["format"] is None
+ assert field["title"] == "Well depth"
+ assert field["description"].startswith("Total depth of the finished well")
+ assert field["x-ogc-unit"] == "https://qudt.org/vocab/unit/FT"
+ assert field["x-ogc-unitLang"] == "QUDT"
+
+
+def test_describe_fields_falls_back_without_raising(caplog):
+ described = describe_fields(
+ "ogc_water_wells", {"not_documented": {"type": "string"}}
+ )
+
+ assert described["not_documented"] == {
+ "type": "string",
+ "title": "Not Documented",
+ }
+ assert "not_documented" in caplog.text
+
+
+def test_describe_fields_returns_fresh_dicts():
+ # pygeoapi's get_collection_schema assigns the provider's field dict into
+ # its response and then mutates it in place. Handing out references into
+ # the cached YAML would let one request's mutations leak into the next.
+ fields = {"well_depth": {"type": "number"}}
+
+ first = describe_fields("ogc_water_wells", fields)
+ first["well_depth"]["x-ogc-role"] = "id"
+ first["well_depth"].pop("description")
+
+ second = describe_fields("ogc_water_wells", fields)
+ assert "x-ogc-role" not in second["well_depth"]
+ assert second["well_depth"]["description"]
+ assert fields["well_depth"] == {"type": "number"}
+
+
+def test_describe_fields_tolerates_empty_input():
+ assert describe_fields("ogc_water_wells", {}) == {}
+ assert describe_fields("ogc_water_wells", None) == {}
From f6af982633c31c36977f80ffbf31ca58c1e2cc5c Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sat, 22 Aug 2026 09:49:38 -0700
Subject: [PATCH 131/151] feat(ogc): serve field descriptions from /schema
Routes all 25 feature collections through a PostgreSQLProvider subclass
that annotates the reflected columns from ogc-field-descriptions.yml.
pygeoapi's get_collection_schema copies each provider field entry into
its response wholesale, so title, description and x-ogc-unit reach the
client with no patching.
The subclass is four lines longer than it looks like it should be.
BaseProvider.fields -- what both /schema and /queryables actually read --
returns self._fields directly and never calls get_fields(), and
GenericSQLProvider.__init__ populates _fields with the raw reflection at
construction. An override that only returned an annotated copy would be
silently discarded, so this one writes back into _fields, with a flag
because the SQL implementation short-circuits on a populated cache.
Co-Authored-By: Claude Opus 5
---
core/feature_provider.py | 58 +++++++++++++++++++++++++++++++
core/pygeoapi-config-internal.yml | 22 ++++++------
core/pygeoapi-config.yml | 28 +++++++--------
core/pygeoapi.py | 2 +-
4 files changed, 84 insertions(+), 26 deletions(-)
create mode 100644 core/feature_provider.py
diff --git a/core/feature_provider.py b/core/feature_provider.py
new file mode 100644
index 000000000..e13751a38
--- /dev/null
+++ b/core/feature_provider.py
@@ -0,0 +1,58 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Feature provider that publishes field-level prose alongside the columns.
+
+pygeoapi's PostgreSQL provider reflects a table and reports each column's
+JSON Schema type and format. It does not read column comments, and there is
+no hook for documentation, so /collections/{id}/schema publishes bare column
+names. This subclass annotates the reflected fields from
+core/ogc-field-descriptions.yml on the way out.
+
+Read docs/ogc-field-descriptions.md before changing this.
+"""
+
+import logging
+
+from pygeoapi.provider.sql import PostgreSQLProvider
+
+from core.ogc_field_metadata import describe_fields
+
+LOGGER = logging.getLogger(__name__)
+
+
+class DescribedPostgreSQLProvider(PostgreSQLProvider):
+ """PostgreSQLProvider that annotates reflected columns with prose."""
+
+ def get_fields(self):
+ """Reflect the table, then annotate the result.
+
+ The write back into ``self._fields`` is the point of this method, not
+ an optimisation. ``BaseProvider.fields`` -- which is what
+ ``get_collection_schema`` and ``get_collection_queryables`` actually
+ read -- returns ``self._fields`` directly and never calls
+ ``get_fields()``. A subclass that only returned an annotated copy
+ would be silently ignored, since ``GenericSQLProvider.__init__``
+ populates ``_fields`` with the raw reflection at construction.
+ """
+ fields = super().get_fields()
+ if fields and not getattr(self, "_fields_described", False):
+ self._fields = describe_fields(self.table, fields)
+ # super().get_fields() short-circuits on a populated _fields, so
+ # without this flag a later call would re-describe the annotated
+ # dict. Harmless today (describe_fields is idempotent) but it
+ # would quietly depend on that staying true.
+ self._fields_described = True
+ return self._fields
diff --git a/core/pygeoapi-config-internal.yml b/core/pygeoapi-config-internal.yml
index c1622bb6c..578bc8daa 100644
--- a/core/pygeoapi-config-internal.yml
+++ b/core/pygeoapi-config-internal.yml
@@ -65,7 +65,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -95,7 +95,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -127,7 +127,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -161,7 +161,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -199,7 +199,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -234,7 +234,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -272,7 +272,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -309,7 +309,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -344,7 +344,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -377,7 +377,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -404,7 +404,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml
index 902ecd8a0..4dbf0c972 100644
--- a/core/pygeoapi-config.yml
+++ b/core/pygeoapi-config.yml
@@ -71,7 +71,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -109,7 +109,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -144,7 +144,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -182,7 +182,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -219,7 +219,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -254,7 +254,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -287,7 +287,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -314,7 +314,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -352,7 +352,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -387,7 +387,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -422,7 +422,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -454,7 +454,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -489,7 +489,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
@@ -521,7 +521,7 @@ resources:
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: feature
- name: PostgreSQL
+ name: core.feature_provider.DescribedPostgreSQLProvider
data:
host: {postgres_host}
port: {postgres_port}
diff --git a/core/pygeoapi.py b/core/pygeoapi.py
index a1d9f4aac..16fbbdd58 100644
--- a/core/pygeoapi.py
+++ b/core/pygeoapi.py
@@ -429,7 +429,7 @@ def _thing_collections_block(
"providers": [
{
"type": "feature",
- "name": "PostgreSQL",
+ "name": "core.feature_provider.DescribedPostgreSQLProvider",
"data": {
"host": host,
"port": port,
From 1d41b5654aca4a27ed9b11a37d43f7db3829066e Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sat, 22 Aug 2026 09:51:14 -0700
Subject: [PATCH 132/151] feat(ogc): carry field descriptions onto /queryables
/schema needed no patch because pygeoapi copies provider field entries
wholesale. get_collection_queryables does the opposite: it builds a fresh
dict per property and hardcodes 'title': k, the raw column name, so the
documentation the provider attaches never survives.
Rather than fork the 130-line handler, this wraps it and merges the
provider's title, description and unit into the JSON it returned. HTML
renders, error statuses and unparseable bodies pass straight through.
The cost is a JSON round trip and one extra table reflection on an
endpoint that is queried rarely; the benefit is that property filtering,
domains, enums and roles stay pygeoapi's code rather than ours.
The patch is applied once in _load_pygeoapi_app before the module is
executed. It is deliberately not config-dependent: pygeoapi.api.itemtypes
is a single module object shared by both mounts, and starlette_app
resolves the handler off it per request, so one patch covers both.
Co-Authored-By: Claude Opus 5
---
core/pygeoapi.py | 7 +++
core/pygeoapi_patches.py | 122 +++++++++++++++++++++++++++++++++++++++
2 files changed, 129 insertions(+)
create mode 100644 core/pygeoapi_patches.py
diff --git a/core/pygeoapi.py b/core/pygeoapi.py
index 16fbbdd58..392c2224a 100644
--- a/core/pygeoapi.py
+++ b/core/pygeoapi.py
@@ -10,6 +10,8 @@
import yaml
from fastapi import FastAPI
+from core.pygeoapi_patches import apply_queryables_patch
+
# Consumed by pygeoapi at import time only; see _load_pygeoapi_app.
_PYGEOAPI_ENV_KEYS = ("PYGEOAPI_CONFIG", "PYGEOAPI_OPENAPI")
@@ -651,6 +653,11 @@ def _load_pygeoapi_app(instance: str, config_path: Path, openapi_path: Path):
# handlers of the app already built for the first one -- both mounts end
# up serving whichever config was loaded last. Give each mount its own
# module object so the two sets of globals can never alias.
+ # Before the module is executed, so the mount's handlers resolve the
+ # patched queryables function. Idempotent and process-wide by nature:
+ # pygeoapi.api.itemtypes is one module object shared by both mounts.
+ apply_queryables_patch()
+
module_name = "pygeoapi.starlette_app"
spec = find_spec(module_name)
if spec is None or spec.loader is None:
diff --git a/core/pygeoapi_patches.py b/core/pygeoapi_patches.py
new file mode 100644
index 000000000..08f6d8801
--- /dev/null
+++ b/core/pygeoapi_patches.py
@@ -0,0 +1,122 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Runtime patches over pygeoapi.
+
+Only one, and only because pygeoapi leaves no hook for it.
+``get_collection_schema`` copies a provider's field entries into its response
+wholesale, so the documentation ``DescribedPostgreSQLProvider`` attaches
+reaches the client untouched. ``get_collection_queryables`` instead builds a
+fresh dict per property and hardcodes ``'title': k`` -- the raw column name --
+dropping every description on the floor.
+
+Rather than fork the 130-line handler, this wraps it and merges the
+provider's own ``title``/``description`` into the JSON it returned. The cost
+is a JSON round trip on a low-traffic endpoint; the benefit is that the rest
+of pygeoapi's logic (property filtering, domains, enums, roles) stays theirs.
+
+Read docs/ogc-field-descriptions.md before changing this, and re-check it on
+any pygeoapi upgrade.
+"""
+
+import json
+import logging
+
+LOGGER = logging.getLogger(__name__)
+
+# Keys taken from the provider's field entry when the handler dropped them.
+DOCUMENTATION_KEYS = ("title", "description", "x-ogc-unit", "x-ogc-unitLang")
+
+_QUERYABLES_PATCHED = False
+
+
+def _documented_fields(api, dataset):
+ """The provider's annotated fields for ``dataset``, or ``{}``.
+
+ Never raises: queryables must keep working for a collection whose backing
+ view is missing, exactly as it did before this patch.
+ """
+ try:
+ from pygeoapi.plugin import load_plugin
+ from pygeoapi.provider import get_provider_by_type
+
+ providers = api.config["resources"][dataset]["providers"]
+ # Builds a second provider for the request: the handler's own instance
+ # is local to it. That costs one table reflection on an endpoint that
+ # is queried rarely and cached downstream.
+ provider = load_plugin("provider", get_provider_by_type(providers, "feature"))
+ return provider.fields or {}
+ except Exception as err: # noqa: BLE001 - documentation is never fatal
+ LOGGER.debug("No documented fields available for %s: %s", dataset, err)
+ return {}
+
+
+def _merge_documentation(payload: str, fields: dict) -> str:
+ document = json.loads(payload)
+ properties = document.get("properties")
+ if not isinstance(properties, dict):
+ return payload
+
+ for name, prop in properties.items():
+ field = fields.get(name)
+ if not isinstance(field, dict):
+ continue
+ for key in DOCUMENTATION_KEYS:
+ value = field.get(key)
+ if value is not None:
+ prop[key] = value
+
+ return json.dumps(document, indent=4)
+
+
+def apply_queryables_patch() -> None:
+ """Make /collections/{id}/queryables carry the provider's field prose.
+
+ Idempotent, and deliberately not config-dependent: pygeoapi.api.itemtypes
+ is a single module object shared by both mounts, and starlette_app
+ resolves the handler off it per request, so patching once before either
+ mount is built covers both.
+ """
+ global _QUERYABLES_PATCHED
+ if _QUERYABLES_PATCHED:
+ return
+
+ import pygeoapi.api.itemtypes as itemtypes
+
+ original = itemtypes.get_collection_queryables
+
+ def get_collection_queryables(api, request, dataset=None):
+ headers, status, content = original(api, request, dataset)
+
+ # Leave HTML rendering, errors, and anything unparseable alone.
+ if status != 200 or not isinstance(content, str):
+ return headers, status, content
+ if not headers.get("Content-Type", "").startswith("application/schema+json"):
+ return headers, status, content
+
+ fields = _documented_fields(api, dataset)
+ if not fields:
+ return headers, status, content
+
+ try:
+ return headers, status, _merge_documentation(content, fields)
+ except (ValueError, TypeError) as err:
+ LOGGER.warning("Could not annotate queryables for %s: %s", dataset, err)
+ return headers, status, content
+
+ get_collection_queryables.__wrapped__ = original
+ itemtypes.get_collection_queryables = get_collection_queryables
+ _QUERYABLES_PATCHED = True
+ LOGGER.debug("Patched pygeoapi get_collection_queryables for field descriptions.")
From ac0513d36431bb3bcb64185dc98a0a551f1ef15e Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sat, 22 Aug 2026 09:53:33 -0700
Subject: [PATCH 133/151] feat(ogc): document EDR parameters and cover the lot
with tests
The EDR provider already returned a title and unit per parameter; it now
routes them through the same YAML the feature collections use, and the
CoverageJSON parameters block carries the documentation where a client
looks for it -- observedProperty.label for the display name, description
for the explanation. Both were the raw parameter name before.
EDR fields are keyed by parameter name rather than column name because
that is what the provider reports. ogc_waterlevels stamps one literal,
'groundwater level', which is documented here. ogc_water_chemistry
carries the analyte text exactly as the laboratory recorded it -- an
open-ended set that is only knowable from the data -- so most chemistry
parameters take a generated title, which is the designed fallback rather
than a gap.
tests/test_ogc_field_descriptions.py covers both endpoints on both
mounts, the internal-only collection, JSON and HTML rendering, and the
fallback path. Two guards earn their keep:
* the drift guard fails when a matview column has no YAML entry, so a
column rename breaks CI instead of quietly degrading the API. EDR
collections are exempt -- their fields are data, not columns.
* the upgrade guard asserts pygeoapi still assigns the provider's field
entry into the schema response. A bump that rebuilds the dict instead,
the way get_collection_queryables already does, would silently drop
every description; this turns that into a red test.
geometry is excluded from the "every property is documented" assertions:
pygeoapi injects it itself, after the provider's fields, carrying only a
format and a role.
Co-Authored-By: Claude Opus 5
---
core/edr_provider.py | 21 +-
core/ogc-field-descriptions.yml | 13 ++
tests/test_ogc_field_descriptions.py | 336 +++++++++++++++++++++++++++
3 files changed, 368 insertions(+), 2 deletions(-)
create mode 100644 tests/test_ogc_field_descriptions.py
diff --git a/core/edr_provider.py b/core/edr_provider.py
index 35815d089..42e47606c 100644
--- a/core/edr_provider.py
+++ b/core/edr_provider.py
@@ -46,6 +46,8 @@
)
from pygeoapi.provider.base_edr import BaseEDRProvider
+from core.ogc_field_metadata import describe_fields, table_entries
+
LOGGER = logging.getLogger(__name__)
GEOGRAPHIC_CRS = {
@@ -161,6 +163,10 @@ def get_fields(self):
"title": row["parameter_name"],
"x-ogc-unit": row["unit"],
}
+ # Same prose source as the feature collections, keyed by parameter
+ # name rather than column name. Parameter names are read out of the
+ # data, so an undocumented analyte keeps its generated title.
+ self._fields = describe_fields(self.table, self._fields)
return self._fields
@property
@@ -344,6 +350,10 @@ def _read(
)
# ------------------------------------------------------- coveragejson
+ def _parameter_documentation(self, parameter_name):
+ """Documented title/description for one EDR parameter, or ``{}``."""
+ return table_entries(self.table).get(parameter_name, {})
+
def _coverage_collection(self, rows):
if not rows:
raise ProviderNoDataError("No data found")
@@ -355,10 +365,17 @@ def _coverage_collection(self, rows):
stations.setdefault(row["thing_id"], []).append(row)
name = row["parameter_name"]
if name not in parameters:
+ # A CoverageJSON client reads observedProperty.label for the
+ # display name and description for the explanation; both were
+ # the raw parameter name before the field metadata existed.
+ entry = self._parameter_documentation(name)
parameters[name] = {
"type": "Parameter",
- "description": {"en": name},
- "observedProperty": {"id": name, "label": {"en": name}},
+ "description": {"en": entry.get("description", name)},
+ "observedProperty": {
+ "id": name,
+ "label": {"en": entry.get("title", name)},
+ },
"unit": {"symbol": row["unit"], "label": {"en": row["unit"]}},
}
diff --git a/core/ogc-field-descriptions.yml b/core/ogc-field-descriptions.yml
index e81353d7b..d3992bd21 100644
--- a/core/ogc-field-descriptions.yml
+++ b/core/ogc-field-descriptions.yml
@@ -2044,3 +2044,16 @@ minor_chemistry_wells:
Units the titanium (total) value is reported in, as the laboratory
recorded them.
+
+# EDR collections. Keys here are parameter names read out of the data, not
+# column names -- ogc_waterlevels stamps a single literal, while
+# ogc_water_chemistry carries the analyte text exactly as the laboratory
+# recorded it, so most chemistry parameters take a generated title.
+waterlevels:
+ groundwater level:
+ title: Groundwater level
+ description: >-
+ Depth from the measuring point down to the water table, as measured by
+ hand during a site visit or logged automatically by a pressure
+ transducer left in the well. Larger values mean the water table is
+ further below the surface.
diff --git a/tests/test_ogc_field_descriptions.py b/tests/test_ogc_field_descriptions.py
new file mode 100644
index 000000000..c2790f6ea
--- /dev/null
+++ b/tests/test_ogc_field_descriptions.py
@@ -0,0 +1,336 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Field-level documentation on /schema and /queryables, both mounts.
+
+`geometry` is excluded from the "every property is documented" assertions
+throughout: pygeoapi injects it itself, after the provider's fields, with only
+a format and an x-ogc-role.
+"""
+
+import pytest
+from fastapi.testclient import TestClient
+
+from core.factory import create_api_app
+from core.dependencies import (
+ admin_function,
+ amp_admin_function,
+ amp_editor_function,
+ amp_viewer_function,
+ editor_function,
+ viewer_function,
+)
+from core.ogc_field_metadata import table_entries
+from tests import override_authentication
+
+GEOMETRY_PROPERTY = "geometry"
+
+
+@pytest.fixture(scope="module")
+def ogc_client():
+ app = create_api_app()
+ for dependency in (
+ admin_function,
+ editor_function,
+ amp_admin_function,
+ amp_editor_function,
+ ):
+ app.dependency_overrides[dependency] = override_authentication(
+ default={"name": "foobar", "sub": "1234567890"}
+ )
+ for dependency in (viewer_function, amp_viewer_function):
+ app.dependency_overrides[dependency] = override_authentication()
+
+ with TestClient(app) as client:
+ yield client
+
+ app.dependency_overrides = {}
+
+
+def _documented_properties(payload):
+ return {
+ name: prop
+ for name, prop in payload["properties"].items()
+ if name != GEOMETRY_PROPERTY
+ }
+
+
+# --------------------------------------------------------------------- schema
+
+
+def test_schema_documents_every_property(ogc_client):
+ response = ogc_client.get("/ogcapi/collections/water_wells/schema")
+
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/schema+json")
+ for name, prop in _documented_properties(response.json()).items():
+ assert prop.get("title"), f"{name} has no title"
+
+
+def test_schema_carries_the_authored_prose(ogc_client):
+ properties = ogc_client.get("/ogcapi/collections/water_wells/schema").json()[
+ "properties"
+ ]
+
+ assert properties["well_depth"]["title"] == "Well depth"
+ assert properties["well_depth"]["description"].startswith(
+ "Total depth of the finished well"
+ )
+ assert properties["well_depth"]["x-ogc-unit"] == "https://qudt.org/vocab/unit/FT"
+ assert properties["well_depth"]["x-ogc-unitLang"] == "QUDT"
+ assert properties["nma_formation_zone"]["title"] == "Legacy formation zone"
+
+
+def test_schema_keeps_pygeoapi_roles(ogc_client):
+ # The annotation must not displace the roles pygeoapi assigns after the
+ # provider hands its fields over.
+ properties = ogc_client.get("/ogcapi/collections/water_wells/schema").json()[
+ "properties"
+ ]
+
+ assert properties["id"]["x-ogc-role"] == "id"
+ assert properties[GEOMETRY_PROPERTY]["x-ogc-role"] == "primary-geometry"
+ assert properties["first_visit_date"]["x-ogc-role"] == "primary-instant"
+
+
+def test_schema_roles_do_not_leak_between_requests(ogc_client):
+ # describe_fields hands out fresh dicts precisely so that pygeoapi's
+ # in-place mutation of one response cannot reach the next one.
+ first = ogc_client.get("/ogcapi/collections/water_wells/schema").json()
+ second = ogc_client.get("/ogcapi/collections/water_wells/schema").json()
+
+ assert first["properties"] == second["properties"]
+ assert "x-ogc-role" not in second["properties"]["well_depth"]
+
+
+def test_derived_collections_document_their_calculated_columns(ogc_client):
+ properties = ogc_client.get(
+ "/ogcapi/collections/depth_to_water_trend_wells/schema"
+ ).json()["properties"]
+
+ assert properties["slope_ft_per_year"]["title"] == "Trend slope"
+ assert "water table is falling" in properties["slope_ft_per_year"]["description"]
+ assert properties["trend_category"]["description"].startswith(
+ "Plain-language reading of the slope"
+ )
+
+
+def test_chemistry_analyte_columns_are_documented(ogc_client):
+ properties = ogc_client.get(
+ "/ogcapi/collections/major_chemistry_results/schema"
+ ).json()["properties"]
+
+ assert properties["tds"]["title"] == "Total dissolved solids"
+ assert properties["tds_units"]["title"] == "Total dissolved solids units"
+ assert properties["ion_balance"]["description"].startswith("Percentage difference")
+
+
+# ---------------------------------------------------------------- both mounts
+
+
+@pytest.mark.parametrize("mount", ["/ogcapi", "/ogcapi-internal"])
+@pytest.mark.parametrize("endpoint", ["schema", "queryables"])
+def test_both_mounts_document_water_wells(ogc_client, mount, endpoint):
+ response = ogc_client.get(f"{mount}/collections/water_wells/{endpoint}")
+
+ assert response.status_code == 200
+ properties = _documented_properties(response.json())
+ assert properties["well_depth"]["title"] == "Well depth"
+ for name, prop in properties.items():
+ assert prop.get("title"), f"{mount} {endpoint}: {name} has no title"
+
+
+def test_internal_only_collection_is_documented(ogc_client):
+ # other_things is published on the internal mount only (BDMS-979); its
+ # backing view is ogc_internal_other_things, so the "ogc_internal_"
+ # prefix has to be stripped for the lookup to land.
+ response = ogc_client.get("/ogcapi-internal/collections/other_things/schema")
+
+ assert response.status_code == 200
+ properties = _documented_properties(response.json())
+ assert properties["well_depth"]["title"] == "Well depth"
+ assert properties["release_status"]["description"]
+
+
+# ----------------------------------------------------------------- rendering
+
+
+@pytest.mark.parametrize("endpoint", ["schema", "queryables"])
+@pytest.mark.parametrize("fmt", ["json", "html"])
+def test_endpoints_render_in_both_formats(ogc_client, endpoint, fmt):
+ response = ogc_client.get(
+ f"/ogcapi/collections/water_wells/{endpoint}", params={"f": fmt}
+ )
+
+ assert response.status_code == 200
+
+
+# --------------------------------------------------------------- drift guard
+
+
+def _feature_collection_tables(client, mount):
+ payload = client.get(f"{mount}/collections").json()
+ return [collection["id"] for collection in payload["collections"]]
+
+
+def test_every_published_column_has_an_entry(ogc_client):
+ """A renamed or added matview column must fail here, not degrade the API.
+
+ EDR collections are excluded on purpose: their fields are analyte names
+ read out of the data, not the backing view's columns.
+ """
+ undocumented = {}
+
+ for mount in ("/ogcapi", "/ogcapi-internal"):
+ for collection_id in _feature_collection_tables(ogc_client, mount):
+ response = ogc_client.get(f"{mount}/collections/{collection_id}/schema")
+ if response.status_code != 200:
+ continue
+ payload = response.json()
+ if payload.get("type") != "object":
+ continue
+ gaps = [
+ name
+ for name, prop in _documented_properties(payload).items()
+ if not prop.get("description")
+ ]
+ if gaps:
+ undocumented[f"{mount}/{collection_id}"] = sorted(gaps)
+
+ assert not undocumented, f"columns with no YAML entry: {undocumented}"
+
+
+def test_fallback_title_for_an_undocumented_column():
+ # The fallback path itself, without needing a real undocumented column in
+ # the database.
+ from core.ogc_field_metadata import describe_fields
+
+ described = describe_fields(
+ "ogc_water_wells", {"brand_new_column": {"type": "string"}}
+ )
+
+ assert described["brand_new_column"]["title"] == "Brand New Column"
+ assert "description" not in described["brand_new_column"]
+
+
+def test_defaults_cover_the_shared_thing_columns():
+ # All 11 thing-type views share one column signature, so a gap in
+ # _defaults would hit every one of them at once.
+ entries = table_entries("ogc_springs")
+
+ for column in (
+ "id",
+ "name",
+ "first_visit_date",
+ "well_depth",
+ "release_status",
+ "elevation",
+ ):
+ assert entries[column]["description"], f"{column} lost its default entry"
+
+
+# ------------------------------------------------------------- upgrade guard
+
+
+def test_pygeoapi_still_passes_provider_fields_through_to_schema():
+ """Guard on the pygeoapi behaviour this whole feature rests on.
+
+ `get_collection_schema` copies each provider field entry into the response
+ wholesale, which is why documentation set by the provider reaches the
+ client. A pygeoapi bump that rebuilds the dict instead -- the way
+ `get_collection_queryables` already does -- would silently drop every
+ description. Fail loudly here instead.
+ """
+ import inspect
+
+ from pygeoapi.api import get_collection_schema
+
+ source = inspect.getsource(get_collection_schema)
+
+ assert "schema['properties'][k] = v" in source, (
+ "pygeoapi no longer assigns the provider's field entry into the schema "
+ "response; /schema descriptions need re-checking against the new "
+ "implementation (see docs/ogc-field-descriptions.md)."
+ )
+
+
+# ----------------------------------------------------------------------- EDR
+
+
+def test_edr_schema_documents_its_parameter(ogc_client):
+ response = ogc_client.get("/ogcapi/collections/waterlevels/schema")
+
+ assert response.status_code == 200
+ parameter = response.json()["properties"]["groundwater level"]
+ assert parameter["title"] == "Groundwater level"
+ assert parameter["description"].startswith("Depth from the measuring point")
+
+
+def test_edr_coveragejson_carries_the_parameter_description():
+ # Exercises the CoverageJSON parameters block without a database: the
+ # provider's __init__ opens a connection, which this does not need.
+ from datetime import datetime
+
+ from core.edr_provider import WaterEDRProvider
+
+ provider = object.__new__(WaterEDRProvider)
+ provider.table = "ogc_waterlevels"
+
+ coverage = provider._coverage_collection(
+ [
+ {
+ "thing_id": 1,
+ "station_name": "Test well",
+ "longitude": -106.0,
+ "latitude": 34.0,
+ "datetime": datetime(2024, 1, 1),
+ "value": 42.0,
+ "unit": "ft",
+ "parameter_name": "groundwater level",
+ }
+ ]
+ )
+
+ parameter = coverage["parameters"]["groundwater level"]
+ assert parameter["observedProperty"]["label"]["en"] == "Groundwater level"
+ assert parameter["description"]["en"].startswith("Depth from the measuring point")
+
+
+def test_edr_falls_back_for_an_undocumented_analyte():
+ from datetime import datetime
+
+ from core.edr_provider import WaterEDRProvider
+
+ provider = object.__new__(WaterEDRProvider)
+ provider.table = "ogc_water_chemistry"
+
+ coverage = provider._coverage_collection(
+ [
+ {
+ "thing_id": 1,
+ "station_name": "Test well",
+ "longitude": -106.0,
+ "latitude": 34.0,
+ "datetime": datetime(2024, 1, 1),
+ "value": 1.0,
+ "unit": "mg/L",
+ "parameter_name": "Some Unmapped Analyte",
+ }
+ ]
+ )
+
+ parameter = coverage["parameters"]["Some Unmapped Analyte"]
+ assert parameter["observedProperty"]["label"]["en"] == "Some Unmapped Analyte"
+ assert parameter["description"]["en"] == "Some Unmapped Analyte"
From e3fc68c987cc84739a9cf31e6a75116cca2aa9d1 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sat, 22 Aug 2026 09:54:24 -0700
Subject: [PATCH 134/151] docs(ogc): document the field-description layer
Records where the YAML lives, how to add a field, and why the copy is
keyed by backing relation rather than collection id.
The load-bearing section is the pygeoapi one. Five behaviours of 0.24.0
hold this feature up, none of them part of any public contract -- the
schema pass-through, the in-place mutation of the provider's field dicts,
the queryables rebuild, BaseProvider.fields returning _fields without
calling get_fields(), and starlette_app resolving handlers off a shared
module. Each is written down with its file and line so an upgrade has
something to check against. Also records the COMMENT ON COLUMN rejection,
so the next person does not relitigate it from scratch.
CLAUDE.md gets a pointer in the existing style.
Co-Authored-By: Claude Opus 5
---
CLAUDE.md | 11 +++
docs/ogc-field-descriptions.md | 123 +++++++++++++++++++++++++++++++++
2 files changed, 134 insertions(+)
create mode 100644 docs/ogc-field-descriptions.md
diff --git a/CLAUDE.md b/CLAUDE.md
index 5e5d14a06..a5f9358c7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -204,6 +204,17 @@ ArcGIS Pro cannot send a bearer token at all and neither desktop client can
refresh an Authentik token. Read **`docs/internal-ogc-desktop-gis.md`** before
changing the credential paths.
+### OGC field descriptions
+
+Per-column `title`/`description`/unit for every collection lives in
+`core/ogc-field-descriptions.yml`, keyed by backing relation, and is published
+on `/schema` and `/queryables` through `core/feature_provider.py` and a wrapper
+over pygeoapi's queryables handler. The feature leans on unpinned behaviour of
+the pinned pygeoapi version — most sharply, `BaseProvider.fields` returns
+`self._fields` and never calls `get_fields()`. Read
+**`docs/ogc-field-descriptions.md`** before changing field metadata or
+upgrading pygeoapi.
+
### Database Configuration
The application supports two database modes (configured via `DB_DRIVER` in `.env`):
diff --git a/docs/ogc-field-descriptions.md b/docs/ogc-field-descriptions.md
new file mode 100644
index 000000000..2dd6b04de
--- /dev/null
+++ b/docs/ogc-field-descriptions.md
@@ -0,0 +1,123 @@
+# OGC field descriptions
+
+Collection-level prose — `title`, `description`, `keywords` — lives in
+`core/pygeoapi.py` (`THING_COLLECTIONS`, `EDR_COLLECTIONS`) and the two
+pygeoapi config templates. This document covers the level below it: what an
+individual **column** means, and what unit it is in.
+
+Published through the standard endpoints:
+
+| Endpoint | Standard | Carries |
+| --- | --- | --- |
+| `GET /collections/{id}/schema` | OGC API - Features Part 5 (draft), Common Part 3 | `title`, `description`, `x-ogc-unit`, `x-ogc-unitLang`, plus pygeoapi's own `x-ogc-role` |
+| `GET /collections/{id}/queryables` | OGC API - Features Part 3 | the same `title` and `description` |
+
+Both mounts serve both endpoints. EDR collections additionally carry the
+documentation in their CoverageJSON `parameters` block
+(`observedProperty.label` and `description`).
+
+## Where the copy lives
+
+`core/ogc-field-descriptions.yml`, keyed by **backing relation** with the
+`ogc_` / `ogc_internal_` prefix stripped:
+
+```yaml
+_defaults:
+ id:
+ title: Feature ID
+ description: >-
+ Stable identifier for this feature within the collection.
+
+water_well_summary:
+ water_level_trend_ft_per_year:
+ title: Water-level trend
+ description: >-
+ Slope of a straight line fitted through the well's depth-to-water
+ measurements over time, in feet per year. Positive means the water
+ table is falling.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT-PER-YR
+ x-ogc-unitLang: QUDT
+```
+
+Keying by relation rather than collection id means the public and internal
+mounts share one entry per view, and the provider looks itself up from
+`self.table` with no extra plumbing. `_defaults` applies everywhere and a
+per-table entry wins over it — which matters for a name like `description`,
+whose meaning differs between `locations` and `project_areas`.
+
+Allowed keys: `title`, `description`, `x-ogc-unit`, `x-ogc-unitLang`,
+`x-ogc-propertySeq`. Anything else fails validation at load. Types and formats
+come from the provider's reflection and must never be set here.
+
+### Adding a field
+
+1. Add the entry under the table's block, or under `_defaults` if the column
+ means the same thing in every view that has it.
+2. Say what the value means and what its datum or convention is — not how the
+ view is built. That belongs in the collection description.
+3. Run `uv run pytest tests/test_ogc_field_descriptions.py`.
+
+The 190 chemistry analyte columns are generated:
+
+```bash
+uv run python -m cli.generate_chemistry_field_descriptions
+```
+
+Review the output and paste it into the YAML. The generator reads the analyte
+lists out of the view migration — `core/parameter.json` holds only two field
+parameters, so the lexicon cannot supply this. A hand-written entry in the YAML
+wins over the generated one.
+
+### Why not `COMMENT ON COLUMN`
+
+It would put the prose next to the data, but every wording fix would need an
+Alembic revision and a materialized-view rebuild, and pygeoapi's reflection does
+not read column comments, so a catalog query would be needed anyway. This was
+considered and rejected; please don't relitigate it without a new argument.
+
+## How it reaches the client
+
+`core/feature_provider.py` — `DescribedPostgreSQLProvider` — annotates the
+reflected fields. Every feature collection in both config templates and in
+`_thing_collections_block` names it as its provider.
+
+`core/pygeoapi_patches.py` wraps `get_collection_queryables`.
+
+`core/edr_provider.py` routes its parameter fields through the same YAML.
+
+## What this depends on inside pygeoapi (0.24.0)
+
+These are unpinned behaviours of a pinned version. **Re-check every one of them
+when bumping pygeoapi**; `tests/test_ogc_field_descriptions.py` guards the first.
+
+1. **`pygeoapi/api/__init__.py::get_collection_schema`** (~line 1082) assigns the
+ provider's field entry into the response wholesale
+ (`schema['properties'][k] = v`), so anything the provider attaches passes
+ through. This is why `/schema` needs no patch.
+2. It then **mutates that same dict in place** — `v.pop('format', None)`, and
+ assigns `x-ogc-role`. `describe_fields()` therefore returns fresh dicts;
+ handing out references into the cached YAML would let one request's
+ mutations leak into the next.
+3. **`pygeoapi/api/itemtypes.py::get_collection_queryables`** (~line 198) builds
+ a fresh dict per property and hardcodes `'title': k`. Hence the patch.
+4. **`pygeoapi/provider/base.py::BaseProvider.fields`** (~line 107) returns
+ `self._fields` directly and **never calls `get_fields()`**, while
+ `GenericSQLProvider.__init__` (~line 143) calls `get_fields()` once at
+ construction. A `get_fields()` override that only returns an annotated copy
+ is silently discarded — it must write back into `self._fields`.
+5. **`pygeoapi/starlette_app.py`** imports `pygeoapi.api.itemtypes` as a module
+ and resolves handlers off it per request, so rebinding the module attribute
+ reaches both mounts even though each gets its own `starlette_app` module
+ object.
+
+## Deliberate gaps
+
+- **`geometry`** carries no title. pygeoapi injects it after the provider's
+ fields with only a `format` and `x-ogc-role`, so it is excluded from the
+ "every property is documented" assertions.
+- **`ogc_water_chemistry` parameters** are the analyte text exactly as the
+ laboratory recorded it — open-ended, alias-ridden, and only knowable from the
+ data. Undocumented analytes get a generated title from the parameter name.
+- **A missing entry never fails a request.** It yields a generated title
+ (`depth_to_water_bgs` → `Depth To Water Bgs`) and a logged warning. The drift
+ guard in the tests is what fails the build.
From 598f1acc7168743aa742667ec6e5d6769e8434c7 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sat, 22 Aug 2026 10:10:40 -0700
Subject: [PATCH 135/151] fix(ogc): stop EDR field dicts leaking between
requests
Cleanup pass on the field-description change.
WaterEDRProvider caches its fields and handed the cached dicts straight
back, so pygeoapi's in-place mutation of what a provider returns -- it
pops format and assigns x-ogc-role while building /schema -- accumulated
on the cache and reached the next response. The feature provider was
already safe because describe_fields copies; this closes the same hole on
the EDR side.
The EDR schema test is now data-independent. Parameter fields are read
out of the data rather than reflected from columns, so it passed alone
and failed in the full suite, where earlier tests had removed the
water-level rows it was reading. It skips with a reason when there is
nothing to assert against; the CoverageJSON test covers the same lookup
without needing any rows.
Also confirmed by hand: OpenAPI generation still succeeds against an
unreachable database, so the app starts before the views exist.
Co-Authored-By: Claude Opus 5
---
core/edr_provider.py | 13 ++++++++++---
tests/test_ogc_field_descriptions.py | 9 ++++++++-
2 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/core/edr_provider.py b/core/edr_provider.py
index 42e47606c..eba54880d 100644
--- a/core/edr_provider.py
+++ b/core/edr_provider.py
@@ -146,9 +146,16 @@ def _has_column(self, column):
# -------------------------------------------------------------- fields
def get_fields(self):
- """Return the parameter-name fields present in the backing view."""
+ """Return the parameter-name fields present in the backing view.
+
+ Each call hands back fresh per-field dicts. pygeoapi's
+ get_collection_schema mutates what a provider returns in place --
+ popping ``format``, assigning ``x-ogc-role`` -- so returning the
+ cached dicts themselves would let one request's edits accumulate on
+ the next one's response.
+ """
if self._fields:
- return self._fields
+ return {name: dict(field) for name, field in self._fields.items()}
try:
rows = self._fetch(
f"SELECT DISTINCT parameter_name, unit " # noqa: S608 (trusted table)
@@ -167,7 +174,7 @@ def get_fields(self):
# name rather than column name. Parameter names are read out of the
# data, so an undocumented analyte keeps its generated title.
self._fields = describe_fields(self.table, self._fields)
- return self._fields
+ return {name: dict(field) for name, field in self._fields.items()}
@property
def fields(self):
diff --git a/tests/test_ogc_field_descriptions.py b/tests/test_ogc_field_descriptions.py
index c2790f6ea..4d9f1c641 100644
--- a/tests/test_ogc_field_descriptions.py
+++ b/tests/test_ogc_field_descriptions.py
@@ -273,7 +273,14 @@ def test_edr_schema_documents_its_parameter(ogc_client):
response = ogc_client.get("/ogcapi/collections/waterlevels/schema")
assert response.status_code == 200
- parameter = response.json()["properties"]["groundwater level"]
+ properties = response.json()["properties"]
+ if "groundwater level" not in properties:
+ # EDR fields are read out of the data, not reflected from columns, so
+ # this assertion only has something to bite on when the suite has left
+ # water-level rows behind. The CoverageJSON test below covers the same
+ # lookup without needing any.
+ pytest.skip("no water-level rows in ogc_waterlevels for this database state")
+ parameter = properties["groundwater level"]
assert parameter["title"] == "Groundwater level"
assert parameter["description"].startswith("Depth from the measuring point")
From 9fc69668792cb1eb9534ae40cea874c1b4178355 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sat, 22 Aug 2026 10:22:08 -0700
Subject: [PATCH 136/151] feat(ogc): populate the schema view's Values column
pygeoapi's HTML schema table renders a Values column from each property's
`enum`, and it was empty for every collection: the SQL provider reports
only type and format, and implements no get_domains(), so
?profile=actual-domain has nothing to offer either. Nothing anywhere
filled the key in.
Field entries can now set it two ways, and the distinction is the point:
* `enum:` literally, for values the view's own SQL decides --
trend_category's four CASE outcomes, and the 'C' the geothermal views
stamp after converting temperatures.
* `enum-lexicon:` naming a category in core/lexicon.json, expanded on the
way out, for anything the lexicon governs -- thing_type,
release_status, well_construction_method, well_pump_type, and
elevation_method. The vocabulary is never copied, so it cannot drift
from the file that seeds it, and a category with no terms fails
validation at load rather than publishing an empty column.
Not populated from SELECT DISTINCT. `enum` is a JSON Schema constraint,
not a sample: a value absent from today's data is not thereby invalid.
The queryables patch carries `enum` too, but yields to one pygeoapi
produced itself, so ?profile=actual-domain still reports the live domain
wherever a provider supports it.
Co-Authored-By: Claude Opus 5
---
core/ogc-field-descriptions.yml | 9 ++++
core/ogc_field_metadata.py | 50 +++++++++++++++++++++
core/pygeoapi_patches.py | 14 ++++--
docs/ogc-field-descriptions.md | 36 ++++++++++++++-
tests/test_ogc_field_descriptions.py | 67 ++++++++++++++++++++++++++++
5 files changed, 171 insertions(+), 5 deletions(-)
diff --git a/core/ogc-field-descriptions.yml b/core/ogc-field-descriptions.yml
index d3992bd21..2c83681fe 100644
--- a/core/ogc-field-descriptions.yml
+++ b/core/ogc-field-descriptions.yml
@@ -29,12 +29,14 @@ _defaults:
description: >-
Controlled-vocabulary type of the monitoring point, such as water well,
spring, or meteorological station.
+ enum-lexicon: thing_type
release_status:
title: Release status
description: >-
Publication state of the record. Only records marked public appear on
the public /ogcapi mount; the authenticated internal mount also carries
private and draft records.
+ enum-lexicon: release_status
first_visit_date:
title: First visit date
description: Date of the earliest Bureau visit on record for this feature.
@@ -86,11 +88,13 @@ _defaults:
description: >-
How the well was constructed, such as air rotary, cable tool, or dug,
from a controlled vocabulary.
+ enum-lexicon: well_construction_method
well_pump_type:
title: Pump type
description: >-
Type of pump installed in the well, such as submersible or windmill,
from a controlled vocabulary.
+ enum-lexicon: well_pump_type
well_pump_depth:
title: Pump intake depth
description: Depth from ground surface to the pump intake.
@@ -238,6 +242,7 @@ water_well_summary:
How the ground-surface elevation was determined, such as GPS survey or
read from a digital elevation model. Governs how much precision the
elevation deserves.
+ enum-lexicon: collection_method
formation_zone:
title: Formation zone
description: Geologic formation the well draws from, as recorded for the well.
@@ -281,6 +286,7 @@ actively_monitored_wells:
description: >-
How the ground-surface elevation was determined, such as GPS survey or
read from a digital elevation model.
+ enum-lexicon: collection_method
formation_zone:
title: Formation zone
description: Geologic formation the well draws from, as recorded for the well.
@@ -355,6 +361,7 @@ depth_to_water_trend_wells:
Plain-language reading of the slope: increasing (water table falling
faster than 0.25 ft/yr), decreasing (rising faster than 0.25 ft/yr),
stable, or not enough data.
+ enum: [increasing, decreasing, stable, not enough data]
water_elevation_wells:
observation_id:
@@ -494,6 +501,7 @@ geothermal_wells_bht:
description: >-
Unit the converted temperatures are reported in. Always Celsius; see
temp_unit_source for what the readings arrived as.
+ enum: [C]
temp_unit_source:
title: Source temperature units
description: >-
@@ -546,6 +554,7 @@ geothermal_wells_temperature_profile:
description: >-
Unit the converted temperatures are reported in. Always Celsius; see
temp_unit_source for what the readings arrived as.
+ enum: [C]
temp_unit_source:
title: Source temperature units
description: >-
diff --git a/core/ogc_field_metadata.py b/core/ogc_field_metadata.py
index f1f514186..7befabcec 100644
--- a/core/ogc_field_metadata.py
+++ b/core/ogc_field_metadata.py
@@ -27,6 +27,7 @@
or upgrading pygeoapi.
"""
+import json
import logging
from pathlib import Path
@@ -48,18 +49,49 @@
"x-ogc-unit",
"x-ogc-unitLang",
"x-ogc-propertySeq",
+ # JSON Schema's own keyword. pygeoapi's HTML renders it as the schema
+ # table's "Values" column, and its queryables handler emits it too.
+ "enum",
+ # Names a category in core/lexicon.json, expanded to `enum` on the way
+ # out so a controlled vocabulary is not duplicated here.
+ "enum-lexicon",
}
)
DEFAULTS_KEY = "_defaults"
+LEXICON_KEY = "enum-lexicon"
+
_CACHE = None
+_LEXICON_CACHE = None
def _metadata_path() -> Path:
return Path(__file__).resolve().parent / "ogc-field-descriptions.yml"
+def _lexicon_path() -> Path:
+ return Path(__file__).resolve().parent / "lexicon.json"
+
+
+def lexicon_terms(category: str) -> list:
+ """Terms in one core/lexicon.json category, in file order.
+
+ The lexicon file seeds the database's controlled vocabularies, so reading
+ it here keeps one source of truth for an enumerated column's valid values
+ -- and keeps this module free of any database dependency.
+ """
+ global _LEXICON_CACHE
+ if _LEXICON_CACHE is None:
+ raw = json.loads(_lexicon_path().read_text(encoding="utf-8"))
+ by_category: dict[str, list] = {}
+ for term in raw.get("terms", []):
+ for name in term.get("categories", []):
+ by_category.setdefault(name, []).append(term["term"])
+ _LEXICON_CACHE = by_category
+ return list(_LEXICON_CACHE.get(category, []))
+
+
def _validate(raw: dict, path: Path) -> dict:
if not isinstance(raw, dict):
raise ValueError(f"{path} must contain a mapping of table -> fields.")
@@ -80,6 +112,17 @@ def _validate(raw: dict, path: Path) -> dict:
f"{path}: {table}.{field} has unsupported keys "
f"{sorted(unknown)}; allowed keys are {sorted(ALLOWED_KEYS)}."
)
+ values = entry.get("enum")
+ if values is not None and (not isinstance(values, list) or not values):
+ raise ValueError(
+ f"{path}: {table}.{field} enum must be a non-empty list."
+ )
+ category = entry.get(LEXICON_KEY)
+ if category is not None and not lexicon_terms(category):
+ raise ValueError(
+ f"{path}: {table}.{field} names lexicon category "
+ f"{category!r}, which has no terms in core/lexicon.json."
+ )
return raw
@@ -136,6 +179,13 @@ def describe_fields(table: str, fields: dict) -> dict:
entry = entries.get(name)
if entry:
for key, value in entry.items():
+ if key == LEXICON_KEY:
+ # Expanded here rather than stored, so the vocabulary stays
+ # defined in one place. An entry may still pin a literal
+ # `enum` instead when the column's values are set by the
+ # view's own SQL rather than by the lexicon.
+ annotated.setdefault("enum", lexicon_terms(value))
+ continue
annotated[key] = value
else:
annotated.setdefault("title", default_title(name))
diff --git a/core/pygeoapi_patches.py b/core/pygeoapi_patches.py
index 08f6d8801..355137627 100644
--- a/core/pygeoapi_patches.py
+++ b/core/pygeoapi_patches.py
@@ -37,7 +37,12 @@
LOGGER = logging.getLogger(__name__)
# Keys taken from the provider's field entry when the handler dropped them.
-DOCUMENTATION_KEYS = ("title", "description", "x-ogc-unit", "x-ogc-unitLang")
+DOCUMENTATION_KEYS = ("title", "description", "x-ogc-unit", "x-ogc-unitLang", "enum")
+
+# Keys the handler may have filled in itself, which we must not overwrite --
+# ?profile=actual-domain asks for the live values in the data, and those beat
+# our authored vocabulary.
+PRESERVED_KEYS = frozenset({"enum"})
_QUERYABLES_PATCHED = False
@@ -75,8 +80,11 @@ def _merge_documentation(payload: str, fields: dict) -> str:
continue
for key in DOCUMENTATION_KEYS:
value = field.get(key)
- if value is not None:
- prop[key] = value
+ if value is None:
+ continue
+ if key in PRESERVED_KEYS and prop.get(key):
+ continue
+ prop[key] = value
return json.dumps(document, indent=4)
diff --git a/docs/ogc-field-descriptions.md b/docs/ogc-field-descriptions.md
index 2dd6b04de..54f134b25 100644
--- a/docs/ogc-field-descriptions.md
+++ b/docs/ogc-field-descriptions.md
@@ -46,8 +46,40 @@ per-table entry wins over it — which matters for a name like `description`,
whose meaning differs between `locations` and `project_areas`.
Allowed keys: `title`, `description`, `x-ogc-unit`, `x-ogc-unitLang`,
-`x-ogc-propertySeq`. Anything else fails validation at load. Types and formats
-come from the provider's reflection and must never be set here.
+`x-ogc-propertySeq`, `enum`, `enum-lexicon`. Anything else fails validation at
+load. Types and formats come from the provider's reflection and must never be
+set here.
+
+### Enumerated values
+
+`enum` is what pygeoapi's HTML schema view renders as its **Values** column,
+and nothing else fills it in — the SQL provider reports only `type` and
+`format`, and implements no `get_domains()`, so `?profile=actual-domain` has
+nothing to offer either.
+
+Two ways to set it, and the distinction matters:
+
+```yaml
+trend_category:
+ enum: [increasing, decreasing, stable, not enough data] # set by the view's SQL
+
+well_pump_type:
+ enum-lexicon: well_pump_type # a controlled vocabulary
+```
+
+Use a literal `enum` only when the view's own SQL decides the values — a `CASE`
+expression or a stamped literal. Use `enum-lexicon` for anything the lexicon
+governs; it names a category in `core/lexicon.json` and is expanded on the way
+out, so the vocabulary is never copied. A category with no terms fails
+validation at load rather than publishing an empty column.
+
+`enum` is a JSON Schema constraint, not a sample: it says these are the *valid*
+values. Do not populate it from `SELECT DISTINCT` — a value absent from today's
+data is not thereby invalid.
+
+On `/queryables` an `enum` pygeoapi produced itself wins over the authored one,
+so `?profile=actual-domain` still reports the live domain where a provider
+supports it.
### Adding a field
diff --git a/tests/test_ogc_field_descriptions.py b/tests/test_ogc_field_descriptions.py
index 4d9f1c641..6779a9461 100644
--- a/tests/test_ogc_field_descriptions.py
+++ b/tests/test_ogc_field_descriptions.py
@@ -341,3 +341,70 @@ def test_edr_falls_back_for_an_undocumented_analyte():
parameter = coverage["parameters"]["Some Unmapped Analyte"]
assert parameter["observedProperty"]["label"]["en"] == "Some Unmapped Analyte"
assert parameter["description"]["en"] == "Some Unmapped Analyte"
+
+
+# ---------------------------------------------------------- enumerated values
+
+
+def test_schema_publishes_enumerated_values(ogc_client):
+ # pygeoapi's HTML schema view renders `enum` as its "Values" column, and
+ # nothing fills it in: the SQL provider reports only type and format, and
+ # implements no get_domains(). These come from the YAML.
+ properties = ogc_client.get(
+ "/ogcapi/collections/depth_to_water_trend_wells/schema"
+ ).json()["properties"]
+
+ assert properties["trend_category"]["enum"] == [
+ "increasing",
+ "decreasing",
+ "stable",
+ "not enough data",
+ ]
+
+
+def test_queryables_publishes_enumerated_values(ogc_client):
+ properties = ogc_client.get(
+ "/ogcapi/collections/depth_to_water_trend_wells/queryables"
+ ).json()["properties"]
+
+ assert "not enough data" in properties["trend_category"]["enum"]
+
+
+def test_lexicon_backed_enums_come_from_the_lexicon(ogc_client):
+ from core.ogc_field_metadata import lexicon_terms
+
+ properties = ogc_client.get("/ogcapi/collections/water_wells/schema").json()[
+ "properties"
+ ]
+
+ assert properties["well_pump_type"]["enum"] == lexicon_terms("well_pump_type")
+ assert properties["well_construction_method"]["enum"] == lexicon_terms(
+ "well_construction_method"
+ )
+ assert properties["release_status"]["enum"] == lexicon_terms("release_status")
+
+
+def test_enum_lexicon_key_is_expanded_not_published(ogc_client):
+ # The YAML shorthand must not reach the client.
+ properties = ogc_client.get("/ogcapi/collections/water_wells/schema").json()[
+ "properties"
+ ]
+
+ assert "enum-lexicon" not in properties["well_pump_type"]
+
+
+def test_enum_entries_validate_against_the_lexicon():
+ # A category with no terms is a typo, and it must fail at load rather than
+ # publish an empty Values column.
+ import pytest as _pytest
+
+ from core.ogc_field_metadata import _validate
+
+ with _pytest.raises(ValueError, match="no terms"):
+ _validate(
+ {"water_wells": {"well_pump_type": {"title": "x", "enum-lexicon": "nope"}}},
+ "test.yml",
+ )
+
+ with _pytest.raises(ValueError, match="non-empty list"):
+ _validate({"water_wells": {"a_column": {"title": "x", "enum": []}}}, "test.yml")
From 54289d0b6d494962b01054530a9a57c96daa2d6c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 24 Aug 2026 15:09:10 +0000
Subject: [PATCH 137/151] chore(deps): Bump dagster-io/dagster-cloud-action
from 1.13.18 to 1.13.19 in the gha-minor-and-patch group (#879)
Bumps the gha-minor-and-patch group with 1 update:
[dagster-io/dagster-cloud-action](https://github.com/dagster-io/dagster-cloud-action).
Updates `dagster-io/dagster-cloud-action` from 1.13.18 to 1.13.19
Commits
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/CD_dagster_branch.yml | 8 ++++----
.github/workflows/CD_dagster_prod.yml | 8 ++++----
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/CD_dagster_branch.yml b/.github/workflows/CD_dagster_branch.yml
index 5b349474e..040f47372 100644
--- a/.github/workflows/CD_dagster_branch.yml
+++ b/.github/workflows/CD_dagster_branch.yml
@@ -92,7 +92,7 @@ jobs:
# working tree either path sets up below.
- name: Prerun checks
id: prerun
- uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v1.13.18
+ uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v1.13.19
# parse_workspace performs its own `actions/checkout`, which cleans the
# working tree -- fine here because the Docker path runs in a separate job
@@ -100,7 +100,7 @@ jobs:
- name: Parse dagster_cloud.yaml
if: steps.prerun.outputs.result == 'docker-deploy'
id: parse
- uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v1.13.18
+ uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v1.13.19
with:
dagster_cloud_file: ${{ env.DAGSTER_CLOUD_FILE }}
@@ -154,7 +154,7 @@ jobs:
# dependency hash changes.
- name: Deploy to Dagster+ branch deployment
if: steps.prerun.outputs.result == 'pex-deploy'
- uses: dagster-io/dagster-cloud-action/actions/build_deploy_python_executable@v1.13.18
+ uses: dagster-io/dagster-cloud-action/actions/build_deploy_python_executable@v1.13.19
with:
dagster_cloud_file: "$GITHUB_WORKSPACE/project-repo/${{ env.DAGSTER_CLOUD_FILE }}"
build_output_dir: "$GITHUB_WORKSPACE/build"
@@ -257,7 +257,7 @@ jobs:
# checkout_repo is false because requirements.txt is generated above and
# a second checkout would discard it.
- name: Deploy to Dagster+ branch deployment
- uses: dagster-io/dagster-cloud-action/actions/serverless_branch_deploy@v1.13.18
+ uses: dagster-io/dagster-cloud-action/actions/serverless_branch_deploy@v1.13.19
with:
organization_id: ${{ vars.DAGSTER_CLOUD_ORGANIZATION_ID }}
dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
diff --git a/.github/workflows/CD_dagster_prod.yml b/.github/workflows/CD_dagster_prod.yml
index aa5bfe34d..833332486 100644
--- a/.github/workflows/CD_dagster_prod.yml
+++ b/.github/workflows/CD_dagster_prod.yml
@@ -94,7 +94,7 @@ jobs:
# working tree either path sets up below.
- name: Prerun checks
id: prerun
- uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v1.13.18
+ uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v1.13.19
# parse_workspace performs its own `actions/checkout`, which cleans the
# working tree -- fine here because the Docker path runs in a separate job
@@ -102,7 +102,7 @@ jobs:
- name: Parse dagster_cloud.yaml
if: steps.prerun.outputs.result == 'docker-deploy'
id: parse
- uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v1.13.18
+ uses: dagster-io/dagster-cloud-action/actions/utils/parse_workspace@v1.13.19
with:
dagster_cloud_file: ${{ env.DAGSTER_CLOUD_FILE }}
@@ -155,7 +155,7 @@ jobs:
# spins up when the dependency hash changes.
- name: Deploy to Dagster+ prod
if: steps.prerun.outputs.result == 'pex-deploy'
- uses: dagster-io/dagster-cloud-action/actions/build_deploy_python_executable@v1.13.18
+ uses: dagster-io/dagster-cloud-action/actions/build_deploy_python_executable@v1.13.19
with:
dagster_cloud_file: "$GITHUB_WORKSPACE/project-repo/${{ env.DAGSTER_CLOUD_FILE }}"
build_output_dir: "$GITHUB_WORKSPACE/build"
@@ -203,7 +203,7 @@ jobs:
# checkout_repo is false because requirements.txt is generated above and
# a second checkout would discard it.
- name: Deploy to Dagster+ prod
- uses: dagster-io/dagster-cloud-action/actions/serverless_prod_deploy@v1.13.18
+ uses: dagster-io/dagster-cloud-action/actions/serverless_prod_deploy@v1.13.19
with:
organization_id: ${{ vars.DAGSTER_CLOUD_ORGANIZATION_ID }}
dagster_cloud_api_token: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
From b7796616bf7e8a3adac0e425f48f688013d763b2 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 24 Aug 2026 15:19:29 +0000
Subject: [PATCH 138/151] chore(deps): Bump the uv-non-major group with 10
updates (#881)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the uv-non-major group with 10 updates:
| Package | From | To |
| --- | --- | --- |
| [charset-normalizer](https://github.com/jawah/charset_normalizer) |
`3.5.0` | `3.5.1` |
|
[cloud-sql-python-connector](https://github.com/GoogleCloudPlatform/cloud-sql-python-connector)
| `1.21.0` | `1.22.0` |
| [gunicorn](https://github.com/benoitc/gunicorn) | `26.0.0` | `26.1.0`
|
| [idna](https://github.com/kjd/idna) | `3.18` | `3.19` |
| [pygments](https://github.com/pygments/pygments) | `2.20.0` | `2.21.0`
|
| [uvicorn](https://github.com/Kludex/uvicorn) | `0.52.3` | `0.52.4` |
| [python-dotenv](https://github.com/theskumar/python-dotenv) | `1.2.2`
| `1.2.3` |
|
[google-api-python-client](https://github.com/googleapis/google-api-python-client)
| `2.198.0` | `2.199.0` |
| [dagster](https://github.com/dagster-io/dagster) | `1.13.18` |
`1.13.19` |
| [dagster-cloud](https://github.com/dagster-io/dagster-cloud) |
`1.13.18` | `1.13.19` |
Updates `charset-normalizer` from 3.5.0 to 3.5.1
Release notes
Sourced from charset-normalizer's
releases .
Version 3.5.1
3.5.1
(2026-08-15)
Changed
Raised upper bound of setuptools to v84 (#794 )
Cache performance access optimization for our CharInfo struct
(prebuilt only).
Fixed
No longer decoding large content when the noise detector output give
a high entropy.
Only impacted large content input >1M bytes.
Changelog
Sourced from charset-normalizer's
changelog .
3.5.1
(2026-08-15)
Changed
Raised upper bound of setuptools to v84 (#794 )
Cache performance access optimization for our CharInfo struct
(prebuilt only).
Fixed
No longer decoding large content when the noise detector output give
a high entropy.
Only impacted large content input >1M bytes.
Commits
e239bdc
Merge pull request #795
from jawah/release-3.5.1
648ad77
docs: update faq
fab2749
docs: write changelog for 3.5.1
7d32774
chore: bump version to 3.5.1
9a69f60
docs: update data/info
5dcc6dd
perf: charinfo cache access optimization in cython
ea3b447
fix: do not validate-decode large payload when md says it's noise
a05917f
chore: allow setuptools 84 builds (#794 )
See full diff in compare
view
Updates `cloud-sql-python-connector` from 1.21.0 to 1.22.0
Release notes
Sourced from cloud-sql-python-connector's
releases .
v1.22.0
1.22.0
(2026-08-18)
Features
Add PSC DNS and Global Write Endpoint support to Python Connector
(#1446 )
(62640f3 )
add support for psycopg (#1452 )
(ba58f8f )
Bug Fixes
psycopg: add platform guard for platforms without
AF_UNIX support (#1466 )
(2dfee66 )
update deps to the latest (#1456 )
(a1bdfc7 )
Documentation
update pg8000 link to new repository location (#1368 )
(2b20167 )
Changelog
Sourced from cloud-sql-python-connector's
changelog .
1.22.0
(2026-08-18)
Features
Add PSC DNS and Global Write Endpoint support to Python Connector
(#1446 )
(62640f3 )
add support for psycopg (#1452 )
(ba58f8f )
Bug Fixes
psycopg: add platform guard for platforms without
AF_UNIX support (#1466 )
(2dfee66 )
update deps to the latest (#1456 )
(a1bdfc7 )
Documentation
update pg8000 link to new repository location (#1368 )
(2b20167 )
Commits
257407a
chore(main): release 1.22.0 (#1455 )
847b34f
chore(deps): update dependency idna to v3.19 (#1468 )
31c415e
ci: skip schedule reporter on forks (#1469 )
2dfee66
fix(psycopg): add platform guard for platforms without AF_UNIX support
(#1466 )
2b20167
docs: update pg8000 link to new repository location (#1368 )
ba58f8f
feat: add support for psycopg (#1452 )
5aa23dc
test: improve unit test coverage and fix deprecation warnings (#1453 )
a1bdfc7
fix: update deps to the latest (#1456 )
62640f3
feat: Add PSC DNS and Global Write Endpoint support to Python Connector
(#1446 )
See full diff in compare
view
Updates `gunicorn` from 26.0.0 to 26.1.0
Release notes
Sourced from gunicorn's
releases .
gunicorn 26.1.0
New Features
Glob patterns in reload_extra_files :
entries containing *, ? or [
are treated as patterns, so ui/*/config.json watches every
view's config
without listing them one by one. Patterns are re-expanded on every
reload
check rather than once at startup, so a file created later starts being
watched without restarting gunicorn, and ** recurses. A
pattern matching
nothing warns instead of failing, since with live expansion it may match
later
(#1643 ,
#3662 ).
Security
Dependency floors raised past known advisories :
every declared floor was
checked against the advisory database. tornado,
h2, setuptools and
pymdown-extensions permitted vulnerable versions and now
require the first
clean release; pytest and httpx were unpinned
and now carry floors. The
tornado example pinned tornado<6, which was
both the source of several
advisories and older than the >=6.5.0 the tornado worker
needs, so the
example could not run as pinned.
Bug Fixes
SIGHUP did not reload the logger configuration :
Arbiter.reload()
re-read the configuration file but kept using the logger built at
startup,
calling only reopen_files() on its existing handlers.
Changes to
logconfig, logconfig_dict,
logconfig_json and loglevel were ignored
until a full restart, which in containers meant replacing the pod. The
existing logger now re-runs its setup on reload, so new handlers,
formats
and levels take effect while the process identity and its listeners are
preserved, and re-running the setup no longer stacks duplicate syslog
handlers. An invalid log configuration on reload is not fatal either:
the
error is reported on stderr, the previous working configuration is
restored
and the master keeps running with it
(#3353 ).
Truncated chunked bodies accepted : RFC 9112 section
7.1.2 ends a chunked
body with 0 CRLF CRLF, the second CRLF being the mandatory
empty trailer
section. ChunkedReader.parse_chunk_size() swallowed the
NoMoreData raised
while scanning for it, so a body cut short right after the last chunk
line was
treated as complete instead of rejected. It now raises
ChunkMissingTerminator
(#3382 ,
#3685 ).
--spew crashed on dynamically generated
code : the trace hook indexed the
2-tuple returned by inspect.getsourcelines() by line number
rather than
indexing the list of lines, so a frame with no __file__
raised
AttributeError: 'int' object has no attribute 'rstrip' on
line 1 and
... (truncated)
Commits
71b59a7
Merge pull request #3698
from benoitc/fix/docker-health-check-readerror
48287de
test: catch every transport error in the docker health check
3110e8c
Merge pull request #3696
from benoitc/docs/roadmap
cc56c41
Merge pull request #3693
from benoitc/release/26.1.0
5cf1f16
docs: surface the roadmap on the site home page
7e35f72
docs: add FastCGI to the roadmap and point items at Ideas
18ddc58
docs: drop the framework and reverse-proxy non-goals from the
roadmap
1ecae56
docs: add a roadmap and make the chat easy to find
ca412e3
docs: sync the Latest changelog page with 26.1.0
640936f
docs: note the dependency security work in 26.1.0
Additional commits viewable in compare
view
Updates `idna` from 3.18 to 3.19
Release notes
Sourced from idna's
releases .
v3.19
Restore the std3_rules option, which had no effect
since changes
to UTS #46
processing in Unicode 16. Note that uts46_remap()
defaults to enabling STD3 rules, so direct callers will see input
containing non-LDH ASCII characters rejected again.
Performance improvements to UTS #46 mapping,
particularly for
ASCII-only domains.
Test on free-threaded CPython with the GIL disabled and document
thread safety.
Expose the Unicode version of the generated tables as
idna.unicode_version, and show it in idna
--version.
Add code, text, codepoint and
position attributes to
IDNAError so that the failed rule and the offending
character can
be identified without parsing the exception message.
The deprecated transitional argument to
encode() and
uts46_remap() is now completely ignored, and gives a
deprecation warning
for the latter.
Reject A-labels that are not the canonical Punycode encoding of
their U-label.
Fix CONTEXTJ violations raising IDNAError instead of
InvalidCodepointContext.
Consistently raise IDNAError for empty labels and
non-ASCII bytes
passed to label helper functions and the incremental codec.
Add property-based tests, extended fuzzing targets, coverage
measurement, and CI checks that the data tables match the generator
output.
Various code quality and tooling improvements.
Thanks to stefan6419846, LouieLuNZ, and Salvatore Corvaglia for
contributions to this release.
Changelog
Sourced from idna's
changelog .
3.19 (2026-08-18)
Restore the std3_rules option, which had no effect
since changes
to UTS #46
processing in Unicode 16. Note that uts46_remap()
defaults to enabling STD3 rules, so direct callers will see input
containing non-LDH ASCII characters rejected again.
Performance improvements to UTS #46 mapping,
particularly for
ASCII-only domains.
Test on free-threaded CPython with the GIL disabled and document
thread safety.
Expose the Unicode version of the generated tables as
idna.unicode_version, and show it in idna
--version.
Add code, text, codepoint and
position attributes to
IDNAError so that the failed rule and the offending
character can
be identified without parsing the exception message.
The deprecated transitional argument to
encode() and
uts46_remap() is now completely ignored, and gives a
deprecation warning
for the latter.
Reject A-labels that are not the canonical Punycode encoding of
their U-label.
Fix CONTEXTJ violations raising IDNAError instead of
InvalidCodepointContext.
Consistently raise IDNAError for empty labels and
non-ASCII bytes
passed to label helper functions and the incremental codec.
Add property-based tests, extended fuzzing targets, coverage
measurement, and CI checks that the data tables match the generator
output.
Various code quality and tooling improvements.
Thanks to stefan6419846, LouieLuNZ, and Salvatore Corvaglia for
contributions to this release.
Commits
03a9a11
Release 3.19
2d2a7ef
Pre-release 3.19rc0
5cce130
Merge pull request #268 from
kjd/fix-std3-regex-alert
3914b75
Split the STD3 disallowed-character range so uppercase is explicit
ce9fd98
Merge pull request #267 from
kjd/housekeeping
809240c
Fail CI when the license copyright year is behind the current year
d9e16c5
Consolidate test fixtures, prune stale gitignore entries, and fix doc
typos
ef30fee
Remove dead code and pare back superfluous comments
b907913
Tighten the version support and Unicode notes in the README
6204cbe
Ignore local build artifacts and stop packaging stray tooling
config
Additional commits viewable in compare
view
Updates `pygments` from 2.20.0 to 2.21.0
Release notes
Sourced from pygments's
releases .
2.21.0
New lexers:
Updated lexers:
Bash: Fix coloured keyword at the beginning of a name (#2926 )
Boogie: Add missing Boogie and Civl Verifier keywords (#3156 )
C#:
Recognize interpolated verbatim strings with either $@
or @$
prefixes (#2685 )
Support dollar-prefixed and multi-quote raw strings (#3129 ,
#2897 )
Recognize union (#3182 )
C/C++:
Add C23/C++26 attributes (#3084 )
Add more C2Y keywords (#3092 )
Highlight a function following a namespace body (#2928 )
Fix C/C++ lexer support for multiline pre-processor comments (#3051 )
Add .ipp as a file extension (#3141 ,
#1008 )
Clojure: Recognize named, octal and unicode character literals such
as
\space and \o377 as a single token (#979 )
Csound: Add missing opcode parameter type letter (#3161 )
CUDA: Derive from the C++ lexer instead of C to highlight C++
constructs such as template, class and
namespace (#3127 )
D: Allow non-ASCII (Unicode) identifiers (#1088 )
Fish: Fix single quote backslash escape (#3138 ,
#2821 )
Go: Various lexer improvements (#3199 )
GoogleSQL: Require a word break after SET (#3167 )
Hexdump: Only match valid digits (#3200 ,
#2847 )
JavaScript: Highlight the arguments object (#3146 )
Jsonnet: Recognize colons in array slice expressions (#2828 )
JSX: Allow apostrophes in element text (#2816 )
Julia: Fix rstrings backslash (#3140 ,
#2537 )
Kotlin: Support companion objects without an explicit name (#2525 )
Kotlin: Don't let a nullable type marker (?) consume
the following
character, so Foo?, and a?:b tokenize
correctly (#2964 )
Kusto: Recognize member-access dots in dynamic objects (#2779 )
Lua: Various improvements (#3143 )
Macaulay2: Update symbols to 1.26.05 (#3120 )
Markdown:
Highlight bold-italics (***...*** and
___...___) (#3067 )
Fix mention regex to support hyphens in usernames (#3139 ,
#3135 )
Markdown, reStructuredText, TiddlyWiki5: Fix wrong token offsets for
embedded code blocks (#3133 )
Mathematica: Recognize \[Name] named-character escapes
such as
\[Nu] instead of emitting an Error token (#3097 )
... (truncated)
Changelog
Sourced from pygments's
changelog .
Version 2.21.0
(released August 17th, 2026)
New lexers:
Updated lexers:
Bash: Fix coloured keyword at the beginning of a name (#2926 )
Boogie: Add missing Boogie and Civl Verifier keywords (#3156 )
C#:
Recognize interpolated verbatim strings with either $@
or @$
prefixes (#2685 )
Support dollar-prefixed and multi-quote raw strings (#3129 ,
#2897 )
Recognize union (#3182 )
C/C++:
Add C23/C++26 attributes (#3084 )
Add more C2Y keywords (#3092 )
Highlight a function following a namespace body (#2928 )
Fix C/C++ lexer support for multiline pre-processor comments (#3051 )
Add .ipp as a file extension (#3141 ,
#1008 )
Clojure: Recognize named, octal and unicode character literals such
as
\space and \o377 as a single token (#979 )
Csound: Add missing opcode parameter type letter (#3161 )
CUDA: Derive from the C++ lexer instead of C to highlight C++
constructs such as template, class and
namespace (#3127 )
D: Allow non-ASCII (Unicode) identifiers (#1088 )
Fish: Fix single quote backslash escape (#3138 ,
#2821 )
Go: Various lexer improvements (#3199 )
GoogleSQL: Require a word break after SET (#3167 )
Hexdump: Only match valid digits (#3200 ,
#2847 )
JavaScript: Highlight the arguments object (#3146 )
Jsonnet: Recognize colons in array slice expressions (#2828 )
JSX: Allow apostrophes in element text (#2816 )
Julia: Fix rstrings backslash (#3140 ,
#2537 )
Kotlin: Support companion objects without an explicit name (#2525 )
Kotlin: Don't let a nullable type marker (?) consume
the following
character, so Foo?, and a?:b tokenize
correctly (#2964 )
Kusto: Recognize member-access dots in dynamic objects (#2779 )
Lua: Various improvements (#3143 )
Macaulay2: Update symbols to 1.26.05 (#3120 )
Markdown:
Highlight bold-italics (***...*** and
___...___) (#3067 )
Fix mention regex to support hyphens in usernames (#3139 ,
#3135 )
Markdown, reStructuredText, TiddlyWiki5: Fix wrong token offsets
for
... (truncated)
Commits
a43b45d
Get ready for the 2.21.0 release.
d8f14cb
Fix version_added for Purescript.
19c5817
Remove superfluous parentheses from PostgresExplainLexer (#3232 )
9992e09
Merge pull request #3191
from jvoisin/dupes
bd22577
Fix regexlint warnings after latest update.
6a62df1
Release preparation: Update the changelog.
aabba32
Merge pull request #3221
from jvoisin/alter
d3441d0
Merge pull request #3225
from jvoisin/caddy
c593f3f
Add a lexer for Caddy
0644b53
Simplify single-character regex alternations to character classes
Additional commits viewable in compare
view
Updates `uvicorn` from 0.52.3 to 0.52.4
Release notes
Sourced from uvicorn's
releases .
Version 0.52.4
Fixed
Remove duplicate Date headers from accepted WebSocket
handshakes with websockets-sansio (#3078 )
Full Changelog : https://github.com/Kludex/uvicorn/compare/0.52.3...0.52.4
Changelog
Sourced from uvicorn's
changelog .
0.52.4 (August 18, 2026)
Fixed
Remove duplicate Date headers from accepted WebSocket
handshakes with websockets-sansio (#3078 )
Commits
Updates `python-dotenv` from 1.2.2 to 1.2.3
Release notes
Sourced from python-dotenv's
releases .
v1.2.3
Fixed
Strip a leading UTF-8 BOM from .env file contents so
the first variable is no longer silently lost when the file is saved
with BOM (e.g. by some JetBrains IDEs on Windows) by [@h1whelan ] in #640
set_key now escapes backslashes, so values containing
them (Windows paths, regular expressions) survive a write/read
round-trip. Quoted values ending in an escaped backslash are no longer
mis-parsed as an escaped quote, which used to swallow the following
lines by [@dchaudhari7177 ]
in #680
dotenv run now prints a friendly error instead of a
traceback when no command is given by [@bbc2 ] in #606
Cache the parsed result for empty .env files so
repeated dotenv_values/load_dotenv calls no
longer re-read the file by [@ReinerBRO ] in #638
Changelog
Sourced from python-dotenv's
changelog .
[1.2.3] - 2026-08-16
Fixed
Strip a leading UTF-8 BOM from .env file contents so
the first variable is no longer silently lost when the file is saved
with BOM (e.g. by some JetBrains IDEs on Windows) by [@h1whelan ] in #640
set_key now escapes backslashes, so values containing
them (Windows paths, regular expressions) survive a write/read
round-trip. Quoted values ending in an escaped backslash are no longer
mis-parsed as an escaped quote, which used to swallow the following
lines by [@dchaudhari7177 ]
in #680
dotenv run now prints a friendly error instead of a
traceback when no command is given by [@bbc2 ] in #606
Cache the parsed result for empty .env files so
repeated dotenv_values/load_dotenv calls no
longer re-read the file by [@ReinerBRO ] in #638
Commits
49515af
Bump version: 1.2.2 → 1.2.3
8ac846f
chore: add release runbook (RELEASING.md) and make release target
bb31c94
docs: add 1.2.3 release notes (#606 ,
#638 ,
#680 )
f7b18d9
fix: round-trip backslashes through set_key (#680 )
751f8c1
ci(deps): bump actions/checkout from 6.0.2 to 6.0.3 in the
github-actions gro...
f1937b6
chore(deps): update mkdocs-include-markdown-plugin requirement from
>=6.0.0 t...
45b9372
chore(deps): update pytest requirement from >=3.9 to >=9.0.3 (#653 )
72896e9
docs: fix broken mkdocs link in CONTRIBUTING.md (#636 )
72754a1
ci(deps): bump peaceiris/actions-gh-pages from 4.0.0 to 4.1.0 in the
github-a...
078325e
ci(security): harden CI/CD supply chain with SHA pinning and
least-privilege ...
Additional commits viewable in compare
view
Updates `google-api-python-client` from 2.198.0 to 2.199.0
Release notes
Sourced from google-api-python-client's
releases .
v2.199.0
Features
... (truncated)
Commits
b0089df
chore(main): release 2.199.0 (#2774 )
7b8de0b
fix: drop support for Python 3.7-3.9 (#2770 )
=2.3.13",
]
@@ -206,7 +206,7 @@ dev = [
# --no-dev`). CI installs them explicitly with `uv sync --group cli`.
cli = [
"openpyxl==3.1.5",
- "google-api-python-client==2.198.0",
+ "google-api-python-client==2.199.0",
]
# Dagster+ code location dependencies. The API runtime never imports
# `automated_ingestion`, so keeping these out of `dependencies` stops dagster
diff --git a/requirements.txt b/requirements.txt
index 8d2abfc84..bf3cdb8d1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -404,179 +404,179 @@ cffi==2.1.1 \
# via
# cryptography
# ocotilloapi
-charset-normalizer==3.5.0 \
- --hash=sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08 \
- --hash=sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6 \
- --hash=sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6 \
- --hash=sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f \
- --hash=sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9 \
- --hash=sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b \
- --hash=sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74 \
- --hash=sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3 \
- --hash=sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b \
- --hash=sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f \
- --hash=sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046 \
- --hash=sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a \
- --hash=sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4 \
- --hash=sha256:143792a43e06dc3b27fc891948406e251502dc19ff9216cd80182b79131be5c5 \
- --hash=sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af \
- --hash=sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19 \
- --hash=sha256:17a0fd0e23961c2c017372e37aabc7ca8fceb9e10ad898977dfb40ad3927baae \
- --hash=sha256:17db18db9a1374d5b9d9a3252f980b4243b0b4efd1df03fac78bb587f6ce98cd \
- --hash=sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e \
- --hash=sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458 \
- --hash=sha256:1a573e1e428f93908e79e04b349717f400e720f2f82285f0aaaf3ee0ff7f4c79 \
- --hash=sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075 \
- --hash=sha256:1cdfed4d7a59333c8220c67dd3be4e7a6c887b67453a64394022dcc919570add \
- --hash=sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a \
- --hash=sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0 \
- --hash=sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6 \
- --hash=sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e \
- --hash=sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26 \
- --hash=sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1 \
- --hash=sha256:2403b489c103e9a18c835863fc6dd54361355c8291d4cafdb37492b683440b9b \
- --hash=sha256:2df26d4134948616be0ece05d0b24d621d3990f37147b5883c52052b613ef1f5 \
- --hash=sha256:301bfc4877c4f4f62b344235ecc58d06c901683801636eef819f88769c315ba2 \
- --hash=sha256:30ae26a1adcd943690dcbbc47f28be762bae9e08ad7442b78c86b1c0dd5a626c \
- --hash=sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3 \
- --hash=sha256:32e6d56dd825205f81e5c45bcebb4df6a11fb2bbf4969a01ef156d6ced90c224 \
- --hash=sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700 \
- --hash=sha256:3587d94b5c9f05c2dc4c3f3d47aba6375ff141a21adae3051d8d4d53e8a937c0 \
- --hash=sha256:3684ebbdffd51329ac44245d1d227d90b965797aa1a8abd026568a1f6ae88811 \
- --hash=sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991 \
- --hash=sha256:38a395079f229a631dece74e24c69c1f612536dd51f345a7d6a98abe2d3e047a \
- --hash=sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be \
- --hash=sha256:3bfbe543d957213fc9a3db4979a8e171b7aa7504c1d737029defdb03a6095a38 \
- --hash=sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee \
- --hash=sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44 \
- --hash=sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb \
- --hash=sha256:420b19411959eec115063229536788e6b32d0a7fa907d6b940317919120d702d \
- --hash=sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394 \
- --hash=sha256:4346a693c08b1d0cfc0e3325bfb0ecd4322fb1a6904d68cf416f8da5e981b234 \
- --hash=sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0 \
- --hash=sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0 \
- --hash=sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e \
- --hash=sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9 \
- --hash=sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3 \
- --hash=sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03 \
- --hash=sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2 \
- --hash=sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca \
- --hash=sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715 \
- --hash=sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f \
- --hash=sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5 \
- --hash=sha256:5a54587f93f2e289f8faf25b35c997d4cc75cf677485ac6f50c985715989f99c \
- --hash=sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293 \
- --hash=sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144 \
- --hash=sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74 \
- --hash=sha256:5f51a19dc52197a20218b05ec5336d0c6b3b09935f838724722032c8d45dc91a \
- --hash=sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e \
- --hash=sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d \
- --hash=sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956 \
- --hash=sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d \
- --hash=sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f \
- --hash=sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6 \
- --hash=sha256:69d647cf158eb6bc9c99503292abed1f2079a2de5859f06a403f8aee6417475d \
- --hash=sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438 \
- --hash=sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e \
- --hash=sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1 \
- --hash=sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9 \
- --hash=sha256:6da562a20a49673fe365b05750e98d03bb2c5f8b8d03562b014c1abb3df739f1 \
- --hash=sha256:6e44bc2780516b3df986d6fe33103c7080cd9dcd5576fe3cb4b0f64309c8f22b \
- --hash=sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b \
- --hash=sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a \
- --hash=sha256:74892fe9f33d204860e782e0a2030bb39f9f0af1e7a24f7d5a5b632df311f655 \
- --hash=sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4 \
- --hash=sha256:7cdded069549b5eae3d5d9bb6c2e5bb4fe83f9b81863e2a193cd747bf197aebb \
- --hash=sha256:7faa47b56070b3dd6f4898ed28528843ab130d53266cb9948d9b1f3bb1a5c5e8 \
- --hash=sha256:7ffc43fe52618fcd7abc6ee0b46aea527db10da73305fcc6aaf9710ac7a33ec7 \
- --hash=sha256:815f143a91983ba3041bba066e492ae3c42de523fb1c699685a1abf3313b7d1b \
- --hash=sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64 \
- --hash=sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c \
- --hash=sha256:830c04a49998b5ed58c8b642c65b7b26419397f52392a64121ba9fd0e95e7f9f \
- --hash=sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731 \
- --hash=sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3 \
- --hash=sha256:85f9e0e2724bbddf05de65e5fb03b73eb23e985b7df4259c1d19feb302eb8dc2 \
- --hash=sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516 \
- --hash=sha256:8b8788f114845c01f2b520e0b91ea58d143276cfc0483aa943e815f7b9555c15 \
- --hash=sha256:8cb9b6892b53bd6d11fa4cde3dbee020b1f0b6656be1fbaa1ec0d4324a7839db \
- --hash=sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc \
- --hash=sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d \
- --hash=sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0 \
- --hash=sha256:9491f594859b68052edebd69e05fb045055a713b57a67974e6c1553b4e503c39 \
- --hash=sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78 \
- --hash=sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603 \
- --hash=sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d \
- --hash=sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea \
- --hash=sha256:9a1d9b13e5e394e13e3c316f0d910d100b17681ff59797f30da1dba032061296 \
- --hash=sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85 \
- --hash=sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76 \
- --hash=sha256:9e0213f3f8a2674a6778be299aea1d6dc6dda015aab86f683bca6d78f81f27bb \
- --hash=sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7 \
- --hash=sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97 \
- --hash=sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897 \
- --hash=sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec \
- --hash=sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d \
- --hash=sha256:a565303d118ea3b94a4b6c076bf568069726be414e43b06d58f7070b076ce11d \
- --hash=sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4 \
- --hash=sha256:a7cb4cd266bd85613367fb85a30cfbf6fe6349919e87e18ca8dba584951bfb8a \
- --hash=sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3 \
- --hash=sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8 \
- --hash=sha256:ac68ebfa549cc623e0e9add2937526340c629ccf667b4da85b7ef5f99e70bbd9 \
- --hash=sha256:aff38231e3171c578b2c449a01afa44e9ff40844597a32873da102394f63d28e \
- --hash=sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288 \
- --hash=sha256:b787efadba00f5da6fe89513bfbe3852d52ca3a448fdec165765cb3b44a80248 \
- --hash=sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3 \
- --hash=sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6 \
- --hash=sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73 \
- --hash=sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b \
- --hash=sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a \
- --hash=sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce \
- --hash=sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f \
- --hash=sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b \
- --hash=sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706 \
- --hash=sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9 \
- --hash=sha256:c5e981a5ac8641381efe6f0029467500661616a530d27bc6eedfe45f840599f8 \
- --hash=sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4 \
- --hash=sha256:c825661dfcf843119ab57cdcac0df7a48e168764c66917bc74f9a42ecb096da9 \
- --hash=sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a \
- --hash=sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757 \
- --hash=sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2 \
- --hash=sha256:d08952c0f14eb56d9dad72a2e17773b5f709c55b28635822d18c4adf38680833 \
- --hash=sha256:d22a083497d2f7d06a57172c5b60ee66cedcf304fde5226d4dfdc94f6180f5b1 \
- --hash=sha256:d2478bd3b2ead3962a484fb802891be40d10049fb74f83e09cb4463fad023fea \
- --hash=sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45 \
- --hash=sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa \
- --hash=sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539 \
- --hash=sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775 \
- --hash=sha256:d74bcf1cdd8ac8267fb216473ce6b112efa07b163536288094541415084d131c \
- --hash=sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636 \
- --hash=sha256:d867cefea33acad8e33a3eb408cca7889a9cf999bd5433d962089d5a13b6e75f \
- --hash=sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45 \
- --hash=sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8 \
- --hash=sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258 \
- --hash=sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2 \
- --hash=sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834 \
- --hash=sha256:dc7f6aca0bdac5e6520c8b6769bda69315fe7cb57f69885f115bc8ca02d1d022 \
- --hash=sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142 \
- --hash=sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80 \
- --hash=sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040 \
- --hash=sha256:e46a37ea7fcf9ae01d71b2e5ece19f1565987f3e308394b829197cbefc061f92 \
- --hash=sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd \
- --hash=sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14 \
- --hash=sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40 \
- --hash=sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4 \
- --hash=sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0 \
- --hash=sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396 \
- --hash=sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa \
- --hash=sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50 \
- --hash=sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d \
- --hash=sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053 \
- --hash=sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698 \
- --hash=sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b \
- --hash=sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49 \
- --hash=sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888 \
- --hash=sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e \
- --hash=sha256:ffdd7ac514301d0a67f7c23b9f2b431ef909a3c3dd6c3766668d0a6f5900c94e
+charset-normalizer==3.5.1 \
+ --hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \
+ --hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \
+ --hash=sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5 \
+ --hash=sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b \
+ --hash=sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f \
+ --hash=sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f \
+ --hash=sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5 \
+ --hash=sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22 \
+ --hash=sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5 \
+ --hash=sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac \
+ --hash=sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838 \
+ --hash=sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90 \
+ --hash=sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626 \
+ --hash=sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4 \
+ --hash=sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369 \
+ --hash=sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b \
+ --hash=sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e \
+ --hash=sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee \
+ --hash=sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1 \
+ --hash=sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102 \
+ --hash=sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8 \
+ --hash=sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8 \
+ --hash=sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9 \
+ --hash=sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf \
+ --hash=sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0 \
+ --hash=sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031 \
+ --hash=sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e \
+ --hash=sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235 \
+ --hash=sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072 \
+ --hash=sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb \
+ --hash=sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c \
+ --hash=sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950 \
+ --hash=sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2 \
+ --hash=sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb \
+ --hash=sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e \
+ --hash=sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6 \
+ --hash=sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b \
+ --hash=sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2 \
+ --hash=sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d \
+ --hash=sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa \
+ --hash=sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2 \
+ --hash=sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818 \
+ --hash=sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032 \
+ --hash=sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71 \
+ --hash=sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96 \
+ --hash=sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687 \
+ --hash=sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8 \
+ --hash=sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3 \
+ --hash=sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61 \
+ --hash=sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9 \
+ --hash=sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1 \
+ --hash=sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a \
+ --hash=sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420 \
+ --hash=sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4 \
+ --hash=sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65 \
+ --hash=sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663 \
+ --hash=sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f \
+ --hash=sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591 \
+ --hash=sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a \
+ --hash=sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18 \
+ --hash=sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e \
+ --hash=sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f \
+ --hash=sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7 \
+ --hash=sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3 \
+ --hash=sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c \
+ --hash=sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3 \
+ --hash=sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7 \
+ --hash=sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96 \
+ --hash=sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486 \
+ --hash=sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3 \
+ --hash=sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6 \
+ --hash=sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b \
+ --hash=sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731 \
+ --hash=sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959 \
+ --hash=sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9 \
+ --hash=sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf \
+ --hash=sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8 \
+ --hash=sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e \
+ --hash=sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f \
+ --hash=sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885 \
+ --hash=sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0 \
+ --hash=sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506 \
+ --hash=sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2 \
+ --hash=sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0 \
+ --hash=sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e \
+ --hash=sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f \
+ --hash=sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e \
+ --hash=sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491 \
+ --hash=sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a \
+ --hash=sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20 \
+ --hash=sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449 \
+ --hash=sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af \
+ --hash=sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c \
+ --hash=sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712 \
+ --hash=sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7 \
+ --hash=sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a \
+ --hash=sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20 \
+ --hash=sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f \
+ --hash=sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3 \
+ --hash=sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9 \
+ --hash=sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e \
+ --hash=sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5 \
+ --hash=sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2 \
+ --hash=sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36 \
+ --hash=sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263 \
+ --hash=sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4 \
+ --hash=sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11 \
+ --hash=sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a \
+ --hash=sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3 \
+ --hash=sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375 \
+ --hash=sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa \
+ --hash=sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d \
+ --hash=sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5 \
+ --hash=sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99 \
+ --hash=sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d \
+ --hash=sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c \
+ --hash=sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488 \
+ --hash=sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6 \
+ --hash=sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc \
+ --hash=sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b \
+ --hash=sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f \
+ --hash=sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00 \
+ --hash=sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10 \
+ --hash=sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598 \
+ --hash=sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6 \
+ --hash=sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962 \
+ --hash=sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c \
+ --hash=sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08 \
+ --hash=sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab \
+ --hash=sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573 \
+ --hash=sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90 \
+ --hash=sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5 \
+ --hash=sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18 \
+ --hash=sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d \
+ --hash=sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af \
+ --hash=sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea \
+ --hash=sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c \
+ --hash=sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b \
+ --hash=sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6 \
+ --hash=sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8 \
+ --hash=sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774 \
+ --hash=sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004 \
+ --hash=sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a \
+ --hash=sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a \
+ --hash=sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2 \
+ --hash=sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2 \
+ --hash=sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa \
+ --hash=sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe \
+ --hash=sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3 \
+ --hash=sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc \
+ --hash=sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795 \
+ --hash=sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d \
+ --hash=sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc \
+ --hash=sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893 \
+ --hash=sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef \
+ --hash=sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d \
+ --hash=sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda \
+ --hash=sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17 \
+ --hash=sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30 \
+ --hash=sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7 \
+ --hash=sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5 \
+ --hash=sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182 \
+ --hash=sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f \
+ --hash=sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9 \
+ --hash=sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada \
+ --hash=sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876 \
+ --hash=sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a \
+ --hash=sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348 \
+ --hash=sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3 \
+ --hash=sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f \
+ --hash=sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0 \
+ --hash=sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f
# via
# ocotilloapi
# requests
@@ -595,9 +595,9 @@ cligj==0.7.2 \
--hash=sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27 \
--hash=sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df
# via rasterio
-cloud-sql-python-connector==1.21.0 \
- --hash=sha256:104e47d1a06448ec1231cf76454cb474f8c1954f970c7c2931b1aa2088805d8d \
- --hash=sha256:a5295627caa588c5c4b7b718d1954b8cf43de1dba9749b60b756984a1ea9cb21
+cloud-sql-python-connector==1.22.0 \
+ --hash=sha256:17cab0a669558963abd0121378216ae2b8ee88d11de718072f2ab4b68a97a183 \
+ --hash=sha256:29e7ed0c4266b49eb9027a8dd247a5fe10ef70b91af3ae78dfaf92d848d49acc
# via ocotilloapi
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
@@ -912,9 +912,9 @@ greenlet==3.5.5 \
# via
# ocotilloapi
# sqlalchemy
-gunicorn==26.0.0 \
- --hash=sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc \
- --hash=sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf
+gunicorn==26.1.0 \
+ --hash=sha256:1413d777bf99d31ebeb08acd354b01f1ecc44db0aa7b811ae7b86c669232e4f7 \
+ --hash=sha256:9f45bcddec5e9dc7a25a3bdccb0c6832f11fd5d4739b1ee36c8d2fec25f1dc86
# via ocotilloapi
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
@@ -935,9 +935,9 @@ httpx==0.28.1 \
# via
# apitally
# ocotilloapi
-idna==3.18 \
- --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
- --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
+idna==3.19 \
+ --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
+ --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via
# anyio
# email-validator
@@ -1575,9 +1575,9 @@ pygeoif==1.6.0 \
# via
# pygeoapi
# pygeofilter
-pygments==2.20.0 \
- --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
- --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
+pygments==2.21.0 \
+ --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \
+ --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c
# via
# ocotilloapi
# rich
@@ -1660,9 +1660,9 @@ python-dateutil==2.9.0.post0 \
# pandas
# pg8000
# pygeoapi
-python-dotenv==1.2.2 \
- --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
- --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
+python-dotenv==1.2.3 \
+ --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9 \
+ --hash=sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35
# via dotenv
python-jose==3.5.0 \
--hash=sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771 \
@@ -2208,9 +2208,9 @@ utm==0.9.0 \
--hash=sha256:1c8ffa6032631379374ceef05e6fea0ad42e9f09be0c3f91f7cc1b23f27be8a7 \
--hash=sha256:767592281e457dfacd71323ac69ff38e2f290d74526af91ca0924637de0e1d53
# via ocotilloapi
-uvicorn==0.52.3 \
- --hash=sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c \
- --hash=sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58
+uvicorn==0.52.4 \
+ --hash=sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86 \
+ --hash=sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1
# via ocotilloapi
werkzeug==3.1.8 \
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
diff --git a/uv.lock b/uv.lock
index 4676cbc44..b3a366f58 100644
--- a/uv.lock
+++ b/uv.lock
@@ -546,117 +546,117 @@ wheels = [
[[package]]
name = "charset-normalizer"
-version = "3.5.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cb/31/4971872b3ed8715346231fb6eb4da8fcba65a4143c189db151ee28a2812b/charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e", size = 169295, upload-time = "2026-08-12T14:35:31.624Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1d/be/cc7b7b6fc41984902c0d31b06f5d9297e67705c1dae9352608e5540fad09/charset_normalizer-3.5.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144", size = 211050, upload-time = "2026-08-12T14:32:47.81Z" },
- { url = "https://files.pythonhosted.org/packages/d3/ae/e3ec8f17313609f43f7b323012fdb1ee37b83432277ca4eceba83e00366c/charset_normalizer-3.5.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f", size = 222768, upload-time = "2026-08-12T14:32:49.027Z" },
- { url = "https://files.pythonhosted.org/packages/c5/50/9f9c0d7ccc1512d49e27a0e7c12c58ec71dfe91698fa4326f058c33e1f1b/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc", size = 193907, upload-time = "2026-08-12T14:32:50.414Z" },
- { url = "https://files.pythonhosted.org/packages/fd/1d/cfe7b745ef7f4c3b7214581955b5a0869ba2ac551a58fc11036281ae167c/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991", size = 197135, upload-time = "2026-08-12T14:32:51.605Z" },
- { url = "https://files.pythonhosted.org/packages/28/55/30fafdcfca9ba616bc394240545e4cd52f4f66dea43ded81b7d2d5274fde/charset_normalizer-3.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64", size = 339892, upload-time = "2026-08-12T14:32:52.82Z" },
- { url = "https://files.pythonhosted.org/packages/f4/08/bdca5fc2bdc36ee443673dc7d12b23885a5a7b282bef85a1a4c3b325b40e/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b", size = 239439, upload-time = "2026-08-12T14:32:54.058Z" },
- { url = "https://files.pythonhosted.org/packages/d1/9e/506c8d7a7722bba7c8cdd78c1b5ef23bda92bfbe0b3e28ea84673d519a0f/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee", size = 227896, upload-time = "2026-08-12T14:32:55.326Z" },
- { url = "https://files.pythonhosted.org/packages/0d/1a/dd828f2b1d6f4bf10821b9a74d866be05ffcdbfcddfc501d6fe6428762a7/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d", size = 262548, upload-time = "2026-08-12T14:32:56.484Z" },
- { url = "https://files.pythonhosted.org/packages/be/81/196d26f6bd78b93e0d451b69082a71027ceeddd4b0be9170b81bb038f824/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f", size = 259986, upload-time = "2026-08-12T14:32:57.661Z" },
- { url = "https://files.pythonhosted.org/packages/3a/6a/5b964a1eb0f9075ecd45083eeb21aaec215334f98bac3d400302ea73875d/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be", size = 249853, upload-time = "2026-08-12T14:32:59.123Z" },
- { url = "https://files.pythonhosted.org/packages/c8/b4/aee3a9d82edd0e931091ef3e9f03e46491ae3590e96e998d0975dadbe17c/charset_normalizer-3.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9", size = 244217, upload-time = "2026-08-12T14:33:00.341Z" },
- { url = "https://files.pythonhosted.org/packages/22/3e/33f72ca11c1b619b220fd9f35905ebd171cbd0e0470f2357e467b9e861ee/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715", size = 241307, upload-time = "2026-08-12T14:33:01.589Z" },
- { url = "https://files.pythonhosted.org/packages/78/27/6029dccba958621c7f3a65136f87c5512d712aef9e890f09512cc171bd03/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08", size = 233305, upload-time = "2026-08-12T14:33:02.866Z" },
- { url = "https://files.pythonhosted.org/packages/18/d7/691c967be459153fe9faf49bf78bc95639ef8bf6dd008f38cc6389a349eb/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2", size = 263465, upload-time = "2026-08-12T14:33:04.166Z" },
- { url = "https://files.pythonhosted.org/packages/7c/2d/9202221be5c90b2a835924191e362690ed8dc8c7d6606100c2bd03fe0f8c/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d", size = 245060, upload-time = "2026-08-12T14:33:05.325Z" },
- { url = "https://files.pythonhosted.org/packages/b3/9a/298772fd0a0cbccadf36451a1cd7eef4b66a11e99b4a7f6fafc47cc62c75/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d", size = 261091, upload-time = "2026-08-12T14:33:06.475Z" },
- { url = "https://files.pythonhosted.org/packages/82/3b/1a11fe66e555dbe2f5714ade6ba74fa29edc9155d9cf1001d4d6ed096aa7/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888", size = 251857, upload-time = "2026-08-12T14:33:07.784Z" },
- { url = "https://files.pythonhosted.org/packages/17/fc/73b817e8af3f1d25ec5cf458d405abba5a144cf9812238a61530f5eac186/charset_normalizer-3.5.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d", size = 139745, upload-time = "2026-08-12T14:33:09.123Z" },
- { url = "https://files.pythonhosted.org/packages/b3/fb/ddb66303c86f7dc5043a457dad9fa82b4d6d0cb97094f9bcdde21693fd58/charset_normalizer-3.5.0-cp313-cp313-win32.whl", hash = "sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e", size = 177217, upload-time = "2026-08-12T14:33:10.305Z" },
- { url = "https://files.pythonhosted.org/packages/fb/88/6018cc8d76ea2b7cb02918f37e23e86c261d1a102713d7e88d2cfb8b211c/charset_normalizer-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a", size = 198896, upload-time = "2026-08-12T14:33:11.51Z" },
- { url = "https://files.pythonhosted.org/packages/20/2e/04c0bbfc8d9abf91959f7a3d207d45cbf63a8116984caae2381890019bb5/charset_normalizer-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b", size = 179193, upload-time = "2026-08-12T14:33:12.726Z" },
- { url = "https://files.pythonhosted.org/packages/43/14/d098868dac5ff27e0258f548b1c74c6484be528384965d8fcf8fc6a4011d/charset_normalizer-3.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2", size = 211664, upload-time = "2026-08-12T14:33:14.153Z" },
- { url = "https://files.pythonhosted.org/packages/e7/da/a944b32a46601ae5a4c3499e8d64ecd14fe82313f00da74dcdf00273a0b4/charset_normalizer-3.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636", size = 224375, upload-time = "2026-08-12T14:33:15.472Z" },
- { url = "https://files.pythonhosted.org/packages/f7/db/eabb5996be2f529744755e7b2fc9396eff4a64961f034e7fd49d54b9afb2/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b", size = 194364, upload-time = "2026-08-12T14:33:16.607Z" },
- { url = "https://files.pythonhosted.org/packages/78/65/4ad3c5be108930310d8003f5602861d5b89f728293b9f09c3a4837f7ba10/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45", size = 197643, upload-time = "2026-08-12T14:33:17.88Z" },
- { url = "https://files.pythonhosted.org/packages/3d/39/8fee3201b98d52289be60a775797d69be05a04fb6cfb48c1587dad33e649/charset_normalizer-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6", size = 341384, upload-time = "2026-08-12T14:33:19.239Z" },
- { url = "https://files.pythonhosted.org/packages/a7/dd/9e757101d1f76c35c0643684ba499ac3a181fb2b264c68174bf727d627e8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73", size = 241637, upload-time = "2026-08-12T14:33:20.619Z" },
- { url = "https://files.pythonhosted.org/packages/eb/e4/7857023015400bc4aa0a82fbcca29fa2dc7ec25f971a130764cb2dc7a589/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8", size = 226170, upload-time = "2026-08-12T14:33:21.773Z" },
- { url = "https://files.pythonhosted.org/packages/7d/ae/8b52935b304f7b6bbf33151ed2b75266b09aa4b6f8f04230d948885b2577/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b", size = 265093, upload-time = "2026-08-12T14:33:22.999Z" },
- { url = "https://files.pythonhosted.org/packages/dc/78/6e838f6bb059f2c0afc60a4e7f294252f043c254656ad4114c50302cae4d/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd", size = 262789, upload-time = "2026-08-12T14:33:24.214Z" },
- { url = "https://files.pythonhosted.org/packages/c2/08/189b27e51fddc9d6b3695331da0e31792c1d88b953ad854e57f06e9b2cc8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c", size = 250580, upload-time = "2026-08-12T14:33:25.707Z" },
- { url = "https://files.pythonhosted.org/packages/ac/55/64854e99b25841f83e8e37d9df2f3d1f96f693439f80e5fabd542a7e47ab/charset_normalizer-3.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458", size = 245008, upload-time = "2026-08-12T14:33:26.971Z" },
- { url = "https://files.pythonhosted.org/packages/4f/01/7720c904fa635d4260b4dced6029cf3d298c57b26741365d5a8d28c54043/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700", size = 243892, upload-time = "2026-08-12T14:33:28.237Z" },
- { url = "https://files.pythonhosted.org/packages/70/50/7bfcb327631d4870c720872b548745f6ec8baa044d51c21b5d1d32ac4e3a/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a", size = 230996, upload-time = "2026-08-12T14:33:29.511Z" },
- { url = "https://files.pythonhosted.org/packages/24/51/40c45d6d940c04005ed721aa54bdebf1ebb2930f8a2ae537e8d60484fb27/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec", size = 265834, upload-time = "2026-08-12T14:33:30.689Z" },
- { url = "https://files.pythonhosted.org/packages/eb/d4/ef7a227ef89d215b47f9df79c3966610b17faa13bb2f236989207a631622/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3", size = 245544, upload-time = "2026-08-12T14:33:31.859Z" },
- { url = "https://files.pythonhosted.org/packages/37/a9/a4ca9156964ded61c7718eba410ce11be2fd2b263fda4bcf08367b6578cd/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0", size = 264110, upload-time = "2026-08-12T14:33:33.13Z" },
- { url = "https://files.pythonhosted.org/packages/38/6a/838364bb8702229c6e5f8b23f80ff0f052a12dfaf3113a12fd6acbe92a44/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb", size = 252303, upload-time = "2026-08-12T14:33:34.98Z" },
- { url = "https://files.pythonhosted.org/packages/9c/5f/d88032edce951f499a2321cf7ae0d35a043c74be12bc22d81084cc7afbcc/charset_normalizer-3.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053", size = 139964, upload-time = "2026-08-12T14:33:36.195Z" },
- { url = "https://files.pythonhosted.org/packages/37/ae/1c4a46b6b00d1c34d2ee355ef99ad6173674166800d1af0f05f85028d513/charset_normalizer-3.5.0-cp314-cp314-win32.whl", hash = "sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e", size = 179790, upload-time = "2026-08-12T14:33:37.356Z" },
- { url = "https://files.pythonhosted.org/packages/01/51/f94dcf34fa8eba48c1fb89b6490a5f1426e19488fe5f38aac6c648c99057/charset_normalizer-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4", size = 203723, upload-time = "2026-08-12T14:33:38.639Z" },
- { url = "https://files.pythonhosted.org/packages/9f/ba/91d386870b5d9e4b0d8c4034f63877cc2e47b99c81ef05f3e6d42bf9a53f/charset_normalizer-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834", size = 183423, upload-time = "2026-08-12T14:33:39.899Z" },
- { url = "https://files.pythonhosted.org/packages/f1/c9/534ecb17b7fb95f9052c4a44cf316316a27d4a8f73e8475ff55e778dcdd7/charset_normalizer-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6", size = 368967, upload-time = "2026-08-12T14:33:41.093Z" },
- { url = "https://files.pythonhosted.org/packages/3c/b2/ad7c3242d7fe55cd55126c22c65cb1b49779782cdf8932fd01d12232d86a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f", size = 239478, upload-time = "2026-08-12T14:33:42.428Z" },
- { url = "https://files.pythonhosted.org/packages/7e/62/77f0b850048e430fc350ec58876b0c020f5c8d0d3956fd1a4d6ae2fa292f/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1", size = 227036, upload-time = "2026-08-12T14:33:43.635Z" },
- { url = "https://files.pythonhosted.org/packages/f0/ba/47d951e1a51dddbaad0a1410baf49fb1d897ceb00281568f1183b79bce9a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa", size = 260772, upload-time = "2026-08-12T14:33:44.96Z" },
- { url = "https://files.pythonhosted.org/packages/b0/61/8c7ff4c81b2a88271126acf4b83ab3e31f6d63868b0f01d331eaa0f9cb67/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4", size = 259273, upload-time = "2026-08-12T14:33:46.185Z" },
- { url = "https://files.pythonhosted.org/packages/e1/fd/36129689be08dc287b951306946657ff70d76e287dd57018861f86d0e474/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6", size = 248086, upload-time = "2026-08-12T14:33:47.54Z" },
- { url = "https://files.pythonhosted.org/packages/cf/f8/bcae67f994c8fd31dda445e5ebf84045823c31443fe46f0e9ee6aca99aa0/charset_normalizer-3.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0", size = 242671, upload-time = "2026-08-12T14:33:48.746Z" },
- { url = "https://files.pythonhosted.org/packages/61/92/0472cdad1061c2f0e4d3aee29973eb6e81bb8fe256ff2860cf115b15f1c9/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0", size = 241311, upload-time = "2026-08-12T14:33:50.152Z" },
- { url = "https://files.pythonhosted.org/packages/f2/89/04a03de5d27c77c624d9fcf6287073754bd438df1b58cb7d030c57c2824d/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19", size = 229898, upload-time = "2026-08-12T14:33:51.523Z" },
- { url = "https://files.pythonhosted.org/packages/42/a2/639c4278adcb7ed1f4db608dd9ac19b6774fa2285a96b1c0bdb9c124ccbd/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50", size = 262852, upload-time = "2026-08-12T14:33:52.924Z" },
- { url = "https://files.pythonhosted.org/packages/9d/95/02e34c97bedfd0c5574efb9179c850591acc7f967ba039ed8dd29d332b73/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa", size = 242913, upload-time = "2026-08-12T14:33:54.18Z" },
- { url = "https://files.pythonhosted.org/packages/a3/64/0946aeab6462dad9f160a50dfb4704d3f58a5ee708f085abc2105fbbff0c/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9", size = 257938, upload-time = "2026-08-12T14:33:55.802Z" },
- { url = "https://files.pythonhosted.org/packages/79/77/36787d41ead124746506a4425c729f4f17c68280af8a6a5baa0a598cae86/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40", size = 249467, upload-time = "2026-08-12T14:33:57.081Z" },
- { url = "https://files.pythonhosted.org/packages/65/10/d9f6c5589cd24198d4ce6cd2948191c18e657272f433e5a00d258d9f5c22/charset_normalizer-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6", size = 190624, upload-time = "2026-08-12T14:33:58.449Z" },
- { url = "https://files.pythonhosted.org/packages/6c/81/43e0584a802051a22c725795ebe1df78263abc7de858eef6cdc9b36637e9/charset_normalizer-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3", size = 215902, upload-time = "2026-08-12T14:33:59.753Z" },
- { url = "https://files.pythonhosted.org/packages/30/f3/af6a1160fef0eac4510d035241e11eccf78e5350e4cd4de79e79fe02a5e5/charset_normalizer-3.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897", size = 193452, upload-time = "2026-08-12T14:34:01.017Z" },
- { url = "https://files.pythonhosted.org/packages/42/a4/dee470afb7a55c4f78b6fef37306c51fed17ebf94dbe530798c91d394350/charset_normalizer-3.5.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce", size = 341595, upload-time = "2026-08-12T14:34:02.4Z" },
- { url = "https://files.pythonhosted.org/packages/6a/32/9c3126dc429c6d9d7f79c52681a7c4453ed20a26267c9a8275d7ab620aba/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757", size = 242177, upload-time = "2026-08-12T14:34:03.741Z" },
- { url = "https://files.pythonhosted.org/packages/4b/9d/5b616a887301ff4cc0916b39ba44257390d3da80deeed6e8b6f2f26b14a8/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396", size = 236730, upload-time = "2026-08-12T14:34:04.991Z" },
- { url = "https://files.pythonhosted.org/packages/be/b4/d6d3e70be93ebe5fabef65e4c7ac113e1d1705cbaeb5fb72467e713aca17/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44", size = 265158, upload-time = "2026-08-12T14:34:06.235Z" },
- { url = "https://files.pythonhosted.org/packages/30/e7/3f1fafa87e2643257474f9c4eec609f2193a61d907dce7dd4f3f2390ebd5/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293", size = 262931, upload-time = "2026-08-12T14:34:07.511Z" },
- { url = "https://files.pythonhosted.org/packages/0a/df/ebeb224a949d91829e5e114c6b64372a3c792b00762a9e951ce416f3a32d/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85", size = 251388, upload-time = "2026-08-12T14:34:08.949Z" },
- { url = "https://files.pythonhosted.org/packages/a0/64/9a6ce2e7acc5cf1b4636f78f82e89ff581e06a0216a40678b28bd4d832c4/charset_normalizer-3.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1", size = 251821, upload-time = "2026-08-12T14:34:10.138Z" },
- { url = "https://files.pythonhosted.org/packages/f1/b1/6e69b8056f615e5ccff6b91ca16db2d47922251f016821a300c115267fef/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78", size = 244507, upload-time = "2026-08-12T14:34:11.488Z" },
- { url = "https://files.pythonhosted.org/packages/0f/34/02c15d6a0aa6b934dcdc136b111da63ae857b9fd51cf5505b0736337c2eb/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b", size = 240951, upload-time = "2026-08-12T14:34:12.991Z" },
- { url = "https://files.pythonhosted.org/packages/ae/15/0fe893d3e1c7d111280bd6c4bd4c1e431487a1124a1bcbce78dfeda3a3a8/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f", size = 266162, upload-time = "2026-08-12T14:34:14.232Z" },
- { url = "https://files.pythonhosted.org/packages/55/ea/eca03527307670f5d102c295671a800c404ca958cf94fefd10fc963a72f0/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80", size = 251835, upload-time = "2026-08-12T14:34:15.48Z" },
- { url = "https://files.pythonhosted.org/packages/03/a8/fee5633081e595fe9e191df6f215106c791ad596eddf5e41e39b8ea0f2e2/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9", size = 264314, upload-time = "2026-08-12T14:34:16.679Z" },
- { url = "https://files.pythonhosted.org/packages/cf/fb/17f47ae6ca35b562fb6e6f4b05f7aec6034217353eb4a23aaa3566dc7340/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4", size = 253194, upload-time = "2026-08-12T14:34:17.975Z" },
- { url = "https://files.pythonhosted.org/packages/59/88/f2b0f7ebb92493e925889ff29239b3b0073ffafd91230dbfc69e5cf9389c/charset_normalizer-3.5.0-cp315-cp315-win32.whl", hash = "sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731", size = 179800, upload-time = "2026-08-12T14:34:19.409Z" },
- { url = "https://files.pythonhosted.org/packages/e2/f0/afb5bfdea52fd943b1960403847a276b8e900c6e4cd6a38752321b4eda64/charset_normalizer-3.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040", size = 203726, upload-time = "2026-08-12T14:34:20.656Z" },
- { url = "https://files.pythonhosted.org/packages/fc/71/219783eb691aa2ec879c0e521afdfe2b826f9678eed51b9c039d03e0db2b/charset_normalizer-3.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e", size = 183428, upload-time = "2026-08-12T14:34:21.975Z" },
- { url = "https://files.pythonhosted.org/packages/d1/d0/14aef3b9f80f2593c039d897e89034635b9eb0eb44b6ce5173bbd79ff338/charset_normalizer-3.5.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a", size = 368728, upload-time = "2026-08-12T14:34:23.221Z" },
- { url = "https://files.pythonhosted.org/packages/10/fc/b249466ddbbeffa448b6597631e9091d1f01b5132ff8e7a0e21a6eb72b63/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698", size = 240925, upload-time = "2026-08-12T14:34:24.504Z" },
- { url = "https://files.pythonhosted.org/packages/2b/b9/c17e72aaa1b3e1ca6c184e8025cf138ed492d01a54f85286ff7d31253a4b/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075", size = 234932, upload-time = "2026-08-12T14:34:25.822Z" },
- { url = "https://files.pythonhosted.org/packages/18/d7/f84ef0966bbe216f71029e34e7fa425a16b1682e2a40265e679dedf2b655/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74", size = 261733, upload-time = "2026-08-12T14:34:27.112Z" },
- { url = "https://files.pythonhosted.org/packages/3b/73/3e887fa0781a395339355ed934ab6561ceb5bb52574160f070224039c630/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4", size = 258460, upload-time = "2026-08-12T14:34:28.431Z" },
- { url = "https://files.pythonhosted.org/packages/b3/81/52ebd9849bf9e35d0b21fff115cb6543162a8e1f2f564e8f87121a336b8c/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258", size = 249894, upload-time = "2026-08-12T14:34:29.698Z" },
- { url = "https://files.pythonhosted.org/packages/85/f3/9366492b8a5fe0187de282e001d61345740cf79eb4a5f20181d769be02b5/charset_normalizer-3.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4", size = 249540, upload-time = "2026-08-12T14:34:30.96Z" },
- { url = "https://files.pythonhosted.org/packages/0b/af/28bb5e5dbd3e67cb9196a62781ac2b6d79492f4fc7a069b6ca7d6d6c8d58/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2", size = 242734, upload-time = "2026-08-12T14:34:32.481Z" },
- { url = "https://files.pythonhosted.org/packages/26/d6/7ccfa62b53b40fc06b2d3504825aa400764740bd10cf248fdc4272441b93/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d", size = 239580, upload-time = "2026-08-12T14:34:33.916Z" },
- { url = "https://files.pythonhosted.org/packages/0b/82/71c0c9b046697b8da66b3acefa8d5f92d00a9ef433ad7c3522b971d0369a/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e", size = 263281, upload-time = "2026-08-12T14:34:35.333Z" },
- { url = "https://files.pythonhosted.org/packages/d6/01/d027583c869f40ba980c1c76994adbd522c360a6327e72beb44d7c267385/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394", size = 250027, upload-time = "2026-08-12T14:34:36.991Z" },
- { url = "https://files.pythonhosted.org/packages/2a/e9/6475d739e0ec8bb1236e06263dc3affaffdf947d8114ad27024932f325da/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0", size = 257547, upload-time = "2026-08-12T14:34:38.277Z" },
- { url = "https://files.pythonhosted.org/packages/a5/60/d1f502fcaa048a2aca3ab80bfef8407659c131e4f1792fa805fec14b4960/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97", size = 251718, upload-time = "2026-08-12T14:34:39.562Z" },
- { url = "https://files.pythonhosted.org/packages/c3/69/76343dcf4381a698807ff8a20d89f66bbdd9f6222b0b17740f77ab764335/charset_normalizer-3.5.0-cp315-cp315t-win32.whl", hash = "sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7", size = 190757, upload-time = "2026-08-12T14:34:41.053Z" },
- { url = "https://files.pythonhosted.org/packages/e1/ea/d18147626a1667cc773c42104ab155a4ca5d6d4d174b7a35e01062213ea5/charset_normalizer-3.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775", size = 215431, upload-time = "2026-08-12T14:34:42.501Z" },
- { url = "https://files.pythonhosted.org/packages/47/21/4869598aae0872d94faa5933918a4fe37ab2c5af9d095786e241f9506fed/charset_normalizer-3.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03", size = 193205, upload-time = "2026-08-12T14:34:43.781Z" },
- { url = "https://files.pythonhosted.org/packages/5b/f3/7b523d807cb5e73562ef8acf21d39cdb9d704955327362c781bc3478a73d/charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5", size = 330840, upload-time = "2026-08-12T14:34:45.06Z" },
- { url = "https://files.pythonhosted.org/packages/f0/de/fc68978fe78ca97063c96d764e41ff92ca639948f319271e0ff450e577a2/charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3", size = 251862, upload-time = "2026-08-12T14:34:46.58Z" },
- { url = "https://files.pythonhosted.org/packages/a9/cb/82b41a0ab7fb1a88065f1d78ad32696ad88ea3fe8e25b8189d08833938de/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3", size = 239484, upload-time = "2026-08-12T14:34:47.869Z" },
- { url = "https://files.pythonhosted.org/packages/e8/0c/19608b631f4538f908098d4a2d56a8f79a665e27cc58e9d90479761a9227/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438", size = 230602, upload-time = "2026-08-12T14:34:49.265Z" },
- { url = "https://files.pythonhosted.org/packages/29/db/f648eb30e14eba301aed61e11672156f137905c1bdbb530151abe8065943/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a", size = 259208, upload-time = "2026-08-12T14:34:50.632Z" },
- { url = "https://files.pythonhosted.org/packages/d3/e0/ed2c8bdbac484d69614d6993143aeb6cb0f4dd1561c883402517b623c8ef/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539", size = 253659, upload-time = "2026-08-12T14:34:52.11Z" },
- { url = "https://files.pythonhosted.org/packages/32/08/b4907cb9ec5b521d9d024ced13611240b86ef065c2eb15b3ad2334dc9940/charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706", size = 248821, upload-time = "2026-08-12T14:34:53.399Z" },
- { url = "https://files.pythonhosted.org/packages/12/b2/e2d1abcfbc05822f0030869efb4e9f8a3658e13b4821796d4b62da917327/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26", size = 240271, upload-time = "2026-08-12T14:34:55.09Z" },
- { url = "https://files.pythonhosted.org/packages/dc/f9/4ba127ad610542fa3eabfa41c45bf12d357860a815b3566374ec0188e213/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3", size = 232155, upload-time = "2026-08-12T14:34:56.543Z" },
- { url = "https://files.pythonhosted.org/packages/01/68/40613182366d00bd6dbd5f6c84a926cbd120960e038a8269e9ae7d782762/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e", size = 259674, upload-time = "2026-08-12T14:34:57.815Z" },
- { url = "https://files.pythonhosted.org/packages/0a/53/4574a14fa4c9de4a6c9f31725354bfa40b67f653e6d594ce1654f9a41b32/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49", size = 246122, upload-time = "2026-08-12T14:34:59.337Z" },
- { url = "https://files.pythonhosted.org/packages/5a/02/bd8030d13d92c058ca7b2b9615bbb3169569e144db64d65c149cd45abf5e/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45", size = 255221, upload-time = "2026-08-12T14:35:00.71Z" },
- { url = "https://files.pythonhosted.org/packages/77/9d/10ecd3bcbe2666b3d4d4026c97b48f73990682815db516052a1e8f4a31c5/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8", size = 253450, upload-time = "2026-08-12T14:35:02.217Z" },
- { url = "https://files.pythonhosted.org/packages/e4/0f/d044c4872c0938a84f87b5027a698c0e61bacfc5c3551a4e749ca9b7bc5c/charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516", size = 173594, upload-time = "2026-08-12T14:35:03.845Z" },
- { url = "https://files.pythonhosted.org/packages/10/6b/6046773901f1944b9a89436351529811ee958afc7b774563be9d74a6f0c3/charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74", size = 198959, upload-time = "2026-08-12T14:35:05.187Z" },
- { url = "https://files.pythonhosted.org/packages/ab/a6/b57708ac92aefc8e8389d51d5178129b81f03196da61ee2c23e687b8178a/charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca", size = 267055, upload-time = "2026-08-12T14:35:06.533Z" },
- { url = "https://files.pythonhosted.org/packages/22/c7/754d09943a616937df61e4ba367c409ded2a987e872972098d51a6fcf73b/charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea", size = 67943, upload-time = "2026-08-12T14:35:30.363Z" },
+version = "3.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
+ { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
+ { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
+ { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
+ { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
+ { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
+ { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
+ { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
+ { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
+ { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
+ { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
+ { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
+ { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
+ { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
+ { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
+ { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
+ { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
+ { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
+ { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
+ { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
+ { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
+ { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
+ { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
+ { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
+ { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
+ { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
+ { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
+ { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
+ { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
+ { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
+ { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
+ { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
+ { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
+ { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
+ { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
+ { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
+ { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
+ { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
+ { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
+ { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
]
[[package]]
@@ -685,7 +685,7 @@ wheels = [
[[package]]
name = "cloud-sql-python-connector"
-version = "1.21.0"
+version = "1.22.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -697,9 +697,9 @@ dependencies = [
{ name = "protobuf" },
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ea/a0/e1554a92336ac1df06c51553ba6cf4ec868788a81723130318713441a245/cloud_sql_python_connector-1.21.0.tar.gz", hash = "sha256:a5295627caa588c5c4b7b718d1954b8cf43de1dba9749b60b756984a1ea9cb21", size = 45183, upload-time = "2026-07-24T01:51:15.378Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/92/8a593c8ca143ce78d2564c3dc73ec55610ddf8d6e04555dd5e5b9d8e3bc8/cloud_sql_python_connector-1.22.0.tar.gz", hash = "sha256:17cab0a669558963abd0121378216ae2b8ee88d11de718072f2ab4b68a97a183", size = 48109, upload-time = "2026-08-18T20:56:28.541Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/62/25/99042398d3bf14a03bb59f08f872a49d3cc7b9e34c35b05a3acf0ef350ae/cloud_sql_python_connector-1.21.0-py3-none-any.whl", hash = "sha256:104e47d1a06448ec1231cf76454cb474f8c1954f970c7c2931b1aa2088805d8d", size = 51190, upload-time = "2026-07-24T01:51:13.976Z" },
+ { url = "https://files.pythonhosted.org/packages/87/e9/db3db380b3759081cb0547dc9bdd9d4e40524b587a6a59f19bd931239f94/cloud_sql_python_connector-1.22.0-py3-none-any.whl", hash = "sha256:29e7ed0c4266b49eb9027a8dd247a5fe10ef70b91af3ae78dfaf92d848d49acc", size = 54401, upload-time = "2026-08-18T20:56:27.248Z" },
]
[[package]]
@@ -846,7 +846,7 @@ wheels = [
[[package]]
name = "dagster"
-version = "1.13.18"
+version = "1.13.19"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alembic" },
@@ -878,14 +878,14 @@ dependencies = [
{ name = "universal-pathlib" },
{ name = "watchdog" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/0c/6b/bac47b75b9ddb3301c345255e87e66d66516755a66be55374f739c3b4da4/dagster-1.13.18.tar.gz", hash = "sha256:b443164a1fad04e4da45fbb729b9ed4ffd0cbf0faf8aa7edc9f8a11cc4a29024", size = 3629353, upload-time = "2026-08-14T19:15:09.753Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/3e/23ebd048231c36c7c063a7d972756268ef714deedb58366134a4d3c94ef7/dagster-1.13.19.tar.gz", hash = "sha256:c2b3d06c198dc3bd99be7b2c0ec5022c919b1acf20c058bf652f2498e0119ec7", size = 3629331, upload-time = "2026-08-21T15:12:15.466Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f1/b9/0a37f7460d7391bfb20391a8944d9cc3334cffd0dbd81149efc01ca286ba/dagster-1.13.18-py3-none-any.whl", hash = "sha256:fd9cd4041245e1ae2e71660c45ad6bbc9999489b59dd07d49c09c54d798800c0", size = 2026007, upload-time = "2026-08-14T19:15:07.192Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/46/8dbed1a3dc6299b5e92525ae29295ee264902343b0ad7297a4f537d4ef7f/dagster-1.13.19-py3-none-any.whl", hash = "sha256:22896569d6b371f42394ea36961e9fcc485f61c795aed2a733634979ce74f86b", size = 2025984, upload-time = "2026-08-21T15:12:12.95Z" },
]
[[package]]
name = "dagster-cloud"
-version = "1.13.18"
+version = "1.13.19"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dagster" },
@@ -896,14 +896,14 @@ dependencies = [
{ name = "requests" },
{ name = "typer" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c6/41/1554b9b5a0ccc10488c66d41a1f03569dfe219f53a57ff8401498d7238f5/dagster_cloud-1.13.18.tar.gz", hash = "sha256:a705e6ce04d438187c46fe72a74c649fc6e7bbb18a116a2297559cab3b389331", size = 737131, upload-time = "2026-08-14T19:15:31.319Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d5/5c/993ab1004bbea4bae3e544f520b353dca4ce55ef0b5b0e4d3d9a2e603056/dagster_cloud-1.13.19.tar.gz", hash = "sha256:f6f4c3bede9ac01cb65a2a5b9f5ce68879549bafd7eee025d9a1090642546586", size = 737158, upload-time = "2026-08-21T15:12:39.796Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6f/1e/b9778c03289847be65dffa8fe1ef898e518a3c37395fae4e8d2d2ad9648c/dagster_cloud-1.13.18-py3-none-any.whl", hash = "sha256:d854af1985e54600b6e8bfb34530133289b8956ee0aca5fa7312aef62eda4781", size = 204300, upload-time = "2026-08-14T19:15:29.879Z" },
+ { url = "https://files.pythonhosted.org/packages/93/66/53c3c58a34c0dd6d21e132d6ab63be66211db3ef786806deb43756c59c5b/dagster_cloud-1.13.19-py3-none-any.whl", hash = "sha256:10161ac4643be97b4574f71c5e3a5c3c6d3a34d096f9b2e93acc0e94e0f7a5da", size = 204319, upload-time = "2026-08-21T15:12:38.348Z" },
]
[[package]]
name = "dagster-cloud-cli"
-version = "1.13.18"
+version = "1.13.19"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@@ -918,23 +918,23 @@ dependencies = [
{ name = "typer" },
{ name = "validators" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/27/cc/4eb6b2b63489533a0d8182051a7a54acca917dcd13485ad2fbb268bd2d91/dagster_cloud_cli-1.13.18.tar.gz", hash = "sha256:33a939a0320145beab61d5db8bacde0d40ed72c77328174b130667b9ebe140af", size = 176645, upload-time = "2026-08-14T19:29:52.847Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c1/a8/78afae6454dd746a20a9877f9959318b77cb0f3c0429f82474783a4ef4c2/dagster_cloud_cli-1.13.19.tar.gz", hash = "sha256:ac0546195f1e8faf1316ef8c456facafc843d7740ab3131dd54f6cbab0385273", size = 176647, upload-time = "2026-08-21T15:20:41.095Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/34/65/67d9a1f36999578b6d13517da5f64e835177312e9b6be2d11c9cd3761924/dagster_cloud_cli-1.13.18-py3-none-any.whl", hash = "sha256:7fc5900404049d1d1e8399947e74b80aa41c3ef3a42962ff1ce3e9a083c3cb25", size = 122353, upload-time = "2026-08-14T19:29:51.67Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/72/e5366527dc1505ec300145be1941ed8f9a950273a5fc8594089dc20e6f09/dagster_cloud_cli-1.13.19-py3-none-any.whl", hash = "sha256:7fa35da8a8128e99ad53a09b9dc7f2a7ca688e187dd323ee0075eb5faa55ee1a", size = 122354, upload-time = "2026-08-21T15:20:40.006Z" },
]
[[package]]
name = "dagster-pipes"
-version = "1.13.18"
+version = "1.13.19"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/85/90/6e38aac87786e71cabc003978a9a43aae4e5eae7755c45b7078196ae009f/dagster_pipes-1.13.18.tar.gz", hash = "sha256:29b27cdc386664e8c0842b1cc65f970dcc7c568d9e53a67732452b42cb265cd8", size = 149679, upload-time = "2026-08-14T19:15:41.361Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/f7/a7c6f1c347ae01d2eaa8dbdc36f927a71a19481f1d180c10d297b4044ba5/dagster_pipes-1.13.19.tar.gz", hash = "sha256:df0c3b3582dec0bdd2f19acd12672c2923d5601d35f66b222a2780d3c7247a0d", size = 149679, upload-time = "2026-08-21T15:12:49.982Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6a/41/a57cb37bded94a2f77712ed4af7d0a5306f0014313ee8aacd79b34e8778c/dagster_pipes-1.13.18-py3-none-any.whl", hash = "sha256:76eccd1d3d784223a3954a9064b787f21c96f6cb9c470f8d2e755e7ce768b529", size = 20245, upload-time = "2026-08-14T19:15:40.258Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/54/0dc0f7404eca8c9759336f3cee562b59851197e3c7c1b65ce5047d774165/dagster_pipes-1.13.19-py3-none-any.whl", hash = "sha256:3a4af8d4e72f1b8da673fabf53d1e598236e1274f77327de790e31aab00b8df8", size = 20242, upload-time = "2026-08-21T15:12:48.903Z" },
]
[[package]]
name = "dagster-shared"
-version = "1.13.18"
+version = "1.13.19"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
@@ -944,9 +944,9 @@ dependencies = [
{ name = "tomlkit" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/be/c8/aa3a0b803501437906e9605af83e62bb86dd8860d8797c9d67f72ecfbbde/dagster_shared-1.13.18.tar.gz", hash = "sha256:c081b6cdb1fa79399328e2adaa22fcdf1f336b83fedc5783678b8e40b26eda33", size = 124087, upload-time = "2026-08-14T19:26:53.281Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/d3/5c6090138b27a51c9f92722a05e4fe0f1eebbf1f14badd8ef5d4e32f84ba/dagster_shared-1.13.19.tar.gz", hash = "sha256:76f920b20866e28092fe02983dca4bfba55bd8596b5f5c20429343dfb5d7a39a", size = 124096, upload-time = "2026-08-21T15:27:12.517Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d7/2d/b08a5071ab05243a283ca8159069904c37ec26199c9c33d9f0d32627ae7c/dagster_shared-1.13.18-py3-none-any.whl", hash = "sha256:a549a941494fc6b0a860fffcb7018025294fd63d2d804603848e9711e02c4c39", size = 96420, upload-time = "2026-08-14T19:26:51.923Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/5d/938ba2c23f0b19938b1bad63f03a2bb12a1c43175c02db99dbc3c024542f/dagster_shared-1.13.19-py3-none-any.whl", hash = "sha256:35653847a031ebcf4f9e1b37b4f509938d18adf1088b2331a44182ccf124a898", size = 96420, upload-time = "2026-08-21T15:27:11.392Z" },
]
[[package]]
@@ -1358,7 +1358,7 @@ grpc = [
[[package]]
name = "google-api-python-client"
-version = "2.198.0"
+version = "2.199.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
@@ -1367,9 +1367,9 @@ dependencies = [
{ name = "httplib2" },
{ name = "uritemplate" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/6b/53/0cd38e3a29d72ce45e27feba2ce1cd8049d69af9c48cb14fb164f1be9133/google_api_python_client-2.198.0.tar.gz", hash = "sha256:dfe3e16fb241af6e9c460a33f65085b3450e05cea09364f6b5d8997fb7e43e2a", size = 15060142, upload-time = "2026-06-25T14:32:42.953Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/ff/c58d475046b552754a5ee24d98912506b07ea7ac7f0a434b327ad194ca32/google_api_python_client-2.199.0.tar.gz", hash = "sha256:8150816e22e01b36aa4b7523cdc1a2d2164e81c4de8a9b338785d7ecb4390ec2", size = 15394941, upload-time = "2026-08-20T21:38:39.745Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d5/92/0fc9e7a09eb240c31b879bd8d2e43f81ed1f86c4798b79ead4a083921ab3/google_api_python_client-2.198.0-py3-none-any.whl", hash = "sha256:fabac935474e817da5e662ff61bf7139439d6f92b32d332a7318a2d45931e03e", size = 15644203, upload-time = "2026-06-25T14:32:39.963Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/42/c443badc33d972ee0e0adcd481c32a71f15d3e04c23a7a3d758e4018c633/google_api_python_client-2.199.0-py3-none-any.whl", hash = "sha256:1d2fa0e7f9d68f063b1a9ff7ed290d6e6c93176260487bf3a991e41534ca23a3", size = 15991965, upload-time = "2026-08-20T21:38:37.274Z" },
]
[[package]]
@@ -1636,14 +1636,11 @@ wheels = [
[[package]]
name = "gunicorn"
-version = "26.0.0"
+version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "packaging" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/b8/ec4ba3f6cace4091c34e27478b576bb80f2f06fab80fd42c0ecc785b308f/gunicorn-26.1.0.tar.gz", hash = "sha256:1413d777bf99d31ebeb08acd354b01f1ecc44db0aa7b811ae7b86c669232e4f7", size = 755923, upload-time = "2026-08-18T11:49:39.438Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" },
+ { url = "https://files.pythonhosted.org/packages/19/dc/7a55fc605543fd5cb11c003fbbb21a1911d5e88a582cce6c5e063bf5c176/gunicorn-26.1.0-py3-none-any.whl", hash = "sha256:9f45bcddec5e9dc7a25a3bdccb0c6832f11fd5d4739b1ee36c8d2fec25f1dc86", size = 216237, upload-time = "2026-08-18T11:49:38.001Z" },
]
[[package]]
@@ -1727,11 +1724,11 @@ wheels = [
[[package]]
name = "idna"
-version = "3.18"
+version = "3.19"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
]
[[package]]
@@ -2253,9 +2250,9 @@ requires-dist = [
{ name = "cachetools", specifier = "==7.1.7" },
{ name = "certifi", specifier = "==2026.7.22" },
{ name = "cffi", specifier = "==2.1.1" },
- { name = "charset-normalizer", specifier = "==3.5.0" },
+ { name = "charset-normalizer", specifier = "==3.5.1" },
{ name = "click", specifier = "==8.4.2" },
- { name = "cloud-sql-python-connector", specifier = "==1.21.0" },
+ { name = "cloud-sql-python-connector", specifier = "==1.22.0" },
{ name = "cryptography", specifier = "==50.0.0" },
{ name = "dnspython", specifier = "==2.8.0" },
{ name = "dotenv", specifier = "==0.9.9" },
@@ -2272,11 +2269,11 @@ requires-dist = [
{ name = "google-resumable-media", specifier = "==2.10.1" },
{ name = "googleapis-common-protos", specifier = "==1.75.1" },
{ name = "greenlet", specifier = "==3.5.5" },
- { name = "gunicorn", specifier = "==26.0.0" },
+ { name = "gunicorn", specifier = "==26.1.0" },
{ name = "h11", specifier = "==0.16.0" },
{ name = "httpcore", specifier = "==1.0.9" },
{ name = "httpx", specifier = "==0.28.1" },
- { name = "idna", specifier = "==3.18" },
+ { name = "idna", specifier = "==3.19" },
{ name = "iniconfig", specifier = "==2.3.0" },
{ name = "jinja2", specifier = "==3.1.6" },
{ name = "mako", specifier = "==1.4.1" },
@@ -2300,7 +2297,7 @@ requires-dist = [
{ name = "pydantic", specifier = "==2.12.5" },
{ name = "pydantic-core", specifier = "==2.41.5" },
{ name = "pygeoapi", specifier = "==0.24.0" },
- { name = "pygments", specifier = "==2.20.0" },
+ { name = "pygments", specifier = "==2.21.0" },
{ name = "pyjwt", specifier = "==2.13.0" },
{ name = "pymssql", specifier = ">=2.3.13" },
{ name = "pyproj", specifier = "==3.7.2" },
@@ -2328,13 +2325,13 @@ requires-dist = [
{ name = "tzdata", specifier = "==2026.3" },
{ name = "urllib3", specifier = "==2.7.0" },
{ name = "utm", specifier = "==0.9.0" },
- { name = "uvicorn", specifier = "==0.52.3" },
+ { name = "uvicorn", specifier = "==0.52.4" },
{ name = "yarl", specifier = "==1.24.5" },
]
[package.metadata.requires-dev]
cli = [
- { name = "google-api-python-client", specifier = "==2.198.0" },
+ { name = "google-api-python-client", specifier = "==2.199.0" },
{ name = "openpyxl", specifier = "==3.1.5" },
]
dev = [
@@ -3085,11 +3082,11 @@ wheels = [
[[package]]
name = "pygments"
-version = "2.20.0"
+version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
@@ -3255,11 +3252,11 @@ wheels = [
[[package]]
name = "python-dotenv"
-version = "1.2.2"
+version = "1.2.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
]
[[package]]
@@ -4120,15 +4117,15 @@ wheels = [
[[package]]
name = "uvicorn"
-version = "0.52.3"
+version = "0.52.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/2e/28/64ca011edf31c715b4fad359c587ea52391aaffa125065695590241ff617/uvicorn-0.52.3.tar.gz", hash = "sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58", size = 100621, upload-time = "2026-08-13T16:50:02.899Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/2b/ebd108734a8204c6b4b93c681c9a38c5273b3ccd5d129fee4ffc1d97772c/uvicorn-0.52.3-py3-none-any.whl", hash = "sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c", size = 79859, upload-time = "2026-08-13T16:50:01.323Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" },
]
[[package]]
From 550fc18331be4f7f647a3d0fe87b999cf89fa096 Mon Sep 17 00:00:00 2001
From: Likitha Bommasani
Date: Mon, 24 Aug 2026 12:38:07 -0700
Subject: [PATCH 139/151] fix: expand actively_monitored_wells to include wells
from all groups(BDMS-974/1178) (#866)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
BDMS-974/1178: Expand actively_monitored_wells to include wells from all
groups
### Why
- actively_monitored_wells filtered wells with WHERE lower(trim(g.name))
= 'water level network' — a string match on a group's display name,
restricting the layer to one group even though its name implies all
actively monitored wells.
- If that group were ever renamed, the filter would stop matching and
the layer would silently return zero rows with no error (Section 4.3,
R3).
- A separate proposal to rename this layer to water_level_network_wells
was withdrawn — the name was fine, the filter was just too narrow.
### How
- New Alembic migration (986e0eb85ab3) drops and recreates both
ogc_actively_monitored_wells and its internal mirror
ogc_internal_actively_monitored_wells, removing the group-name
predicate. The status_value = 'Currently monitored' filter is untouched
— that's the real definition of "actively monitored."
- Verified against production data first: confirmed via a real query
that no currently-monitored well has zero group memberships, so the
existing inner-join structure is safe as-is (no need to switch to a LEFT
JOIN).
- Wells belonging to multiple groups are aggregated into one row
(group_ids/group_names/group_types as parallel arrays) rather than one
row per group, so id stays unique for pygeoapi's id_field: id lookups —
verified live that duplicate rows silently broke /items/{id}.
- Deduplicates group_thing_association rows first (no unique constraint
exists on that table) and orders all three arrays by group_id, so they
stay correctly aligned with each other rather than each being sorted
independently.
- Added a test
(test_ogc_actively_monitored_wells_includes_wells_from_other_groups)
proving a well in a group other than Water Level Network now shows up.
- Rewrote the A4/A6 scenarios in ogc-cleanup-sprint1.feature, dropping
the old rename/deprecation-header scenarios and the stale hardcoded
"322" feature-count assertion (never matched real data in any
environment we checked).
### Notes
- Found and separately flagged (not fixed here): a duplicate group row
in prod, "water Level Network" (lowercase w) vs "Water Level Network",
holding one stray well (WL-0428). Worth a data-cleanup follow-up.
---------
Co-authored-by: likithabommasani21 <275146718+likithabommasani21@users.noreply.github.com>
---
...expand_actively_monitored_wells_to_all_.py | 288 ++++++++++++++++++
core/ogc-field-descriptions.yml | 24 +-
tests/features/ogc-cleanup-sprint1.feature | 40 +--
tests/test_ogc.py | 141 ++++++++-
4 files changed, 449 insertions(+), 44 deletions(-)
create mode 100644 alembic/versions/986e0eb85ab3_expand_actively_monitored_wells_to_all_.py
diff --git a/alembic/versions/986e0eb85ab3_expand_actively_monitored_wells_to_all_.py b/alembic/versions/986e0eb85ab3_expand_actively_monitored_wells_to_all_.py
new file mode 100644
index 000000000..0b71d362d
--- /dev/null
+++ b/alembic/versions/986e0eb85ab3_expand_actively_monitored_wells_to_all_.py
@@ -0,0 +1,288 @@
+"""expand actively_monitored_wells to all groups
+
+Drops the "WHERE group name = 'water level network'" restriction so the view
+covers currently-monitored wells in any group, not just one. Public view
+adds a group release_status = 'public' check instead, so draft/private
+groups don't leak through now that any group can show up. A well in
+multiple groups is aggregated into one row (group_ids/group_names/group_types
+as arrays) rather than one row per group, so `id` stays unique -- pygeoapi's
+id_field: id assumes exactly one row per id for /items/{id} lookups.
+
+Revision ID: 986e0eb85ab3
+Revises: c3d4e5f6a7b8
+Create Date: 2026-08-20 10:55:25.697907
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+from sqlalchemy import text
+
+# revision identifiers, used by Alembic.
+revision: str = "986e0eb85ab3"
+down_revision: Union[str, Sequence[str], None] = "c3d4e5f6a7b8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def _drop_view_or_materialized_view(view_name: str) -> None:
+ # DROP VIEW IF EXISTS / DROP MATERIALIZED VIEW IF EXISTS only suppress
+ # "relation does not exist" -- Postgres still raises WrongObjectType if
+ # the relation exists as the other kind, so the relation's actual kind
+ # must be checked first rather than trying both blindly.
+ bind = op.get_bind()
+ relkind = bind.execute(
+ text("SELECT relkind FROM pg_class WHERE oid = to_regclass(:name)"),
+ {"name": view_name},
+ ).scalar()
+ if relkind == "m":
+ op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {view_name}"))
+ elif relkind == "v":
+ op.execute(text(f"DROP VIEW IF EXISTS {view_name}"))
+
+
+def _create_actively_monitored_wells_view(all_groups: bool) -> str:
+ if all_groups:
+ # Aggregated: one row per well, group_ids/group_names/group_types as
+ # arrays, so `id` stays unique even when a well belongs to several
+ # groups. release_status = 'public' is checked on the group row
+ # itself (mirrors _create_project_areas_view's public_only handling)
+ # since any group can appear here now, not just one hardcoded one.
+ # group_thing_association has no unique constraint on
+ # (group_id, thing_id), so distinct_memberships de-dupes before
+ # aggregating; all three arrays are ordered by the same group_id key
+ # so they stay index-aligned with each other (ordering each array by
+ # its own column, e.g. names alphabetically, would desync them).
+ return """
+ CREATE VIEW ogc_actively_monitored_wells AS
+ WITH latest_monitoring_status AS (
+ SELECT DISTINCT ON (sh.target_id)
+ sh.target_id AS thing_id,
+ sh.status_value
+ FROM status_history AS sh
+ WHERE
+ sh.target_table = 'thing'
+ AND sh.status_type = 'Monitoring Status'
+ ORDER BY sh.target_id, sh.start_date DESC, sh.id DESC
+ ),
+ distinct_memberships AS (
+ SELECT DISTINCT
+ gta.thing_id,
+ g.id AS group_id,
+ g.name AS group_name,
+ g.group_type
+ FROM group_thing_association AS gta
+ JOIN "group" AS g ON g.id = gta.group_id
+ WHERE g.release_status = 'public'
+ )
+ SELECT
+ wws.id,
+ wws.name,
+ 'water well'::text AS thing_type,
+ wws.well_depth,
+ wws.elevation,
+ wws.elevation_method,
+ wws.formation_zone,
+ wws.total_water_levels,
+ wws.last_water_level,
+ wws.last_water_level_datetime,
+ wws.min_water_level,
+ wws.max_water_level,
+ wws.water_level_trend_ft_per_year,
+ array_agg(dm.group_id ORDER BY dm.group_id) AS group_ids,
+ array_agg(dm.group_name ORDER BY dm.group_id) AS group_names,
+ array_agg(dm.group_type ORDER BY dm.group_id) AS group_types,
+ wws.point
+ FROM ogc_water_well_summary AS wws
+ JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id
+ JOIN distinct_memberships AS dm ON dm.thing_id = wws.id
+ WHERE lms.status_value = 'Currently monitored'
+ GROUP BY
+ wws.id, wws.name, wws.well_depth, wws.elevation,
+ wws.elevation_method, wws.formation_zone,
+ wws.total_water_levels, wws.last_water_level,
+ wws.last_water_level_datetime, wws.min_water_level,
+ wws.max_water_level, wws.water_level_trend_ft_per_year,
+ wws.point
+ """
+ # Historical (downgrade target): byte-for-byte the pre-fix view, single
+ # group_id/group_name/group_type columns, scoped to one hardcoded group.
+ return """
+ CREATE VIEW ogc_actively_monitored_wells AS
+ WITH latest_monitoring_status AS (
+ SELECT DISTINCT ON (sh.target_id)
+ sh.target_id AS thing_id,
+ sh.status_value
+ FROM status_history AS sh
+ WHERE
+ sh.target_table = 'thing'
+ AND sh.status_type = 'Monitoring Status'
+ ORDER BY sh.target_id, sh.start_date DESC, sh.id DESC
+ )
+ SELECT
+ wws.id,
+ wws.name,
+ 'water well'::text AS thing_type,
+ wws.well_depth,
+ wws.elevation,
+ wws.elevation_method,
+ wws.formation_zone,
+ wws.total_water_levels,
+ wws.last_water_level,
+ wws.last_water_level_datetime,
+ wws.min_water_level,
+ wws.max_water_level,
+ wws.water_level_trend_ft_per_year,
+ g.id AS group_id,
+ g.name AS group_name,
+ g.group_type,
+ wws.point
+ FROM "group" AS g
+ JOIN group_thing_association AS gta ON gta.group_id = g.id
+ JOIN ogc_water_well_summary AS wws ON wws.id = gta.thing_id
+ JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id
+ WHERE lower(trim(g.name)) = 'water level network'
+ AND lms.status_value = 'Currently monitored'
+ """
+
+
+def _create_internal_actively_monitored_wells_view(all_groups: bool) -> str:
+ if all_groups:
+ # Aggregated, same shape as the public view's all_groups branch, but
+ # no release_status filter -- the internal mount is unfiltered by
+ # design, same as its sibling views. See the public branch's comment
+ # for why distinct_memberships + a shared ORDER BY key is needed.
+ return """
+ CREATE VIEW ogc_internal_actively_monitored_wells AS
+ WITH latest_monitoring_status AS (
+ SELECT DISTINCT ON (sh.target_id)
+ sh.target_id AS thing_id,
+ sh.status_value
+ FROM status_history AS sh
+ WHERE
+ sh.target_table = 'thing'
+ AND sh.status_type = 'Monitoring Status'
+ ORDER BY sh.target_id, sh.start_date DESC, sh.id DESC
+ ),
+ distinct_memberships AS (
+ SELECT DISTINCT
+ gta.thing_id,
+ g.id AS group_id,
+ g.name AS group_name,
+ g.group_type
+ FROM group_thing_association AS gta
+ JOIN "group" AS g ON g.id = gta.group_id
+ )
+ SELECT
+ wws.id,
+ wws.name,
+ 'water well'::text AS thing_type,
+ wws.well_depth,
+ wws.elevation,
+ wws.elevation_method,
+ wws.formation_zone,
+ wws.total_water_levels,
+ wws.last_water_level,
+ wws.last_water_level_datetime,
+ wws.min_water_level,
+ wws.max_water_level,
+ wws.water_level_trend_ft_per_year,
+ array_agg(dm.group_id ORDER BY dm.group_id) AS group_ids,
+ array_agg(dm.group_name ORDER BY dm.group_id) AS group_names,
+ array_agg(dm.group_type ORDER BY dm.group_id) AS group_types,
+ wws.point
+ FROM ogc_internal_water_well_summary AS wws
+ JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id
+ JOIN distinct_memberships AS dm ON dm.thing_id = wws.id
+ WHERE lms.status_value = 'Currently monitored'
+ GROUP BY
+ wws.id, wws.name, wws.well_depth, wws.elevation,
+ wws.elevation_method, wws.formation_zone,
+ wws.total_water_levels, wws.last_water_level,
+ wws.last_water_level_datetime, wws.min_water_level,
+ wws.max_water_level, wws.water_level_trend_ft_per_year,
+ wws.point
+ """
+ # Historical (downgrade target): byte-for-byte the pre-fix view.
+ return """
+ CREATE VIEW ogc_internal_actively_monitored_wells AS
+ WITH latest_monitoring_status AS (
+ SELECT DISTINCT ON (sh.target_id)
+ sh.target_id AS thing_id,
+ sh.status_value
+ FROM status_history AS sh
+ WHERE
+ sh.target_table = 'thing'
+ AND sh.status_type = 'Monitoring Status'
+ ORDER BY sh.target_id, sh.start_date DESC, sh.id DESC
+ )
+ SELECT
+ wws.id,
+ wws.name,
+ 'water well'::text AS thing_type,
+ wws.well_depth,
+ wws.elevation,
+ wws.elevation_method,
+ wws.formation_zone,
+ wws.total_water_levels,
+ wws.last_water_level,
+ wws.last_water_level_datetime,
+ wws.min_water_level,
+ wws.max_water_level,
+ wws.water_level_trend_ft_per_year,
+ g.id AS group_id,
+ g.name AS group_name,
+ g.group_type,
+ wws.point
+ FROM "group" AS g
+ JOIN group_thing_association AS gta ON gta.group_id = g.id
+ JOIN ogc_internal_water_well_summary AS wws ON wws.id = gta.thing_id
+ JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id
+ WHERE lower(trim(g.name)) = 'water level network'
+ AND lms.status_value = 'Currently monitored'
+ """
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ _drop_view_or_materialized_view("ogc_actively_monitored_wells")
+ op.execute(text(_create_actively_monitored_wells_view(all_groups=True)))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_actively_monitored_wells IS "
+ "'Actively (currently) monitored wells across all groups for pygeoapi.'"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_internal_actively_monitored_wells")
+ op.execute(text(_create_internal_actively_monitored_wells_view(all_groups=True)))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_internal_actively_monitored_wells IS "
+ "'Actively (currently) monitored wells across all groups, "
+ "for the internal pygeoapi mount.'"
+ )
+ )
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ _drop_view_or_materialized_view("ogc_actively_monitored_wells")
+ op.execute(text(_create_actively_monitored_wells_view(all_groups=False)))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_actively_monitored_wells IS "
+ "'Wells in the Water Level Network group for pygeoapi.'"
+ )
+ )
+
+ _drop_view_or_materialized_view("ogc_internal_actively_monitored_wells")
+ op.execute(text(_create_internal_actively_monitored_wells_view(all_groups=False)))
+ op.execute(
+ text(
+ "COMMENT ON VIEW ogc_internal_actively_monitored_wells IS "
+ "'Unfiltered wells in the Water Level Network group, "
+ "for the internal pygeoapi mount.'"
+ )
+ )
diff --git a/core/ogc-field-descriptions.yml b/core/ogc-field-descriptions.yml
index 2c83681fe..dd01ac954 100644
--- a/core/ogc-field-descriptions.yml
+++ b/core/ogc-field-descriptions.yml
@@ -315,17 +315,19 @@ actively_monitored_wells:
is falling.
x-ogc-unit: https://qudt.org/vocab/unit/FT-PER-YR
x-ogc-unitLang: QUDT
- group_id:
- title: Network ID
- description: Identifier of the monitoring network the well belongs to.
- group_name:
- title: Network name
- description: >-
- Name of the monitoring network the well belongs to. Always the Water
- Level Network in this collection.
- group_type:
- title: Network type
- description: Kind of grouping the network record represents.
+ group_ids:
+ title: Network IDs
+ description: Identifiers of every monitoring network the well belongs to.
+ group_names:
+ title: Network names
+ description: >-
+ Names of every monitoring network the well belongs to, in the same
+ order as group_ids.
+ group_types:
+ title: Network types
+ description: >-
+ Kind of grouping each network record represents, in the same order as
+ group_ids.
depth_to_water_trend_wells:
record_count:
diff --git a/tests/features/ogc-cleanup-sprint1.feature b/tests/features/ogc-cleanup-sprint1.feature
index d747c8bef..bfab8bc9a 100644
--- a/tests/features/ogc-cleanup-sprint1.feature
+++ b/tests/features/ogc-cleanup-sprint1.feature
@@ -135,47 +135,29 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
| /ogcapi/collections/water_wells/items?datetime=2020-01-01/2024-01-01 |
# ---------------------------------------------------------------------------
- # A4 — Fix brittle SQL filter in actively_monitored_wells
+ # A4/A6 — actively_monitored_wells covers all groups; name unchanged
# ---------------------------------------------------------------------------
- # Note: these scenarios use actively_monitored_wells (the pre-A6 name). After A6
- # is applied, requests to this ID redirect via the 90-day deprecation route.
+ # A6's original rename to water_level_network_wells was withdrawn: the name
+ # was fine, the filter was too narrow. A4 (brittle group-name filter) and A6
+ # (naming) are resolved together by the same SQL change.
@backend @ogc-data-currency @sprint-1 @high-priority @A4
- Scenario: Layer result set is unchanged after replacing the string filter
- Given the "Water Level Network" group exists in the database
+ Scenario: Layer includes a well from a group other than Water Level Network
+ Given a well is currently monitored under the "Test Other Group" group
When a client requests features from the actively_monitored_wells layer
- Then the feature count is 322
+ Then the response includes that well
@backend @ogc-data-currency @sprint-1 @high-priority @A4
Scenario: Layer is resilient to group display name changes
Given the "Water Level Network" group display name is changed to "Water Level Monitoring Network"
When a client requests features from the actively_monitored_wells layer
- Then the feature count is 322
-
- # ---------------------------------------------------------------------------
- # A6 — Rename actively_monitored_wells to water_level_network_wells
- # ---------------------------------------------------------------------------
-
- @backend @ogc-naming @sprint-1 @high-priority @A6
- Scenario: Layer is accessible under the new ID water_level_network_wells
- When a client requests /ogcapi/collections/water_level_network_wells/items
- Then the response HTTP status is 200
- And the response Content-Type is "application/geo+json"
+ Then wells in that group still appear in the response
@backend @ogc-naming @sprint-1 @high-priority @A6
- Scenario: The renamed layer is discoverable in the collections catalog
- Given the rename to water_level_network_wells has been applied across service configuration
+ Scenario: Layer keeps its existing ID and is discoverable in the collections catalog
When a client requests /ogcapi/collections
- Then the water_level_network_wells collection appears in the response
- And the actively_monitored_wells collection does not appear in the response
-
- @backend @ogc-naming @sprint-1 @high-priority @A6
- Scenario: Old layer ID returns deprecation headers during the 90-day grace period
- When a client requests /ogcapi/collections/actively_monitored_wells/items
- Then the response HTTP status is 200
- And the response includes a Deprecation header
- And the response includes a Sunset header containing a valid RFC 7231 date
- And the response includes a Link header pointing to the water_level_network_wells collection
+ Then the actively_monitored_wells collection appears in the response
+ And the water_level_network_wells collection does not appear in the response
# ---------------------------------------------------------------------------
# A11 — Stand up authenticated internal OGC mount at /ogcapi-internal
diff --git a/tests/test_ogc.py b/tests/test_ogc.py
index 8b49ac0a6..35ed69ac6 100644
--- a/tests/test_ogc.py
+++ b/tests/test_ogc.py
@@ -491,15 +491,15 @@ def test_ogc_actively_monitored_wells_exposes_water_level_network_group_wells(
row = session.execute(
text(
- "SELECT group_id, group_name, group_type "
+ "SELECT group_ids, group_names, group_types "
"FROM ogc_actively_monitored_wells WHERE id = :thing_id"
),
{"thing_id": water_well_thing.id},
).one()
- assert row.group_id == group.id
- assert row.group_name == "Water Level Network"
- assert row.group_type == "Monitoring Plan"
+ assert row.group_ids == [group.id]
+ assert row.group_names == ["Water Level Network"]
+ assert row.group_types == ["Monitoring Plan"]
session.delete(status_history)
session.delete(group_assoc)
@@ -559,6 +559,139 @@ def test_ogc_actively_monitored_wells_excludes_latest_not_currently_monitored(
session.commit()
+def test_ogc_actively_monitored_wells_aggregates_multiple_groups(
+ water_well_thing,
+ groundwater_level_observation,
+):
+ with session_ctx() as session:
+ session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_well_summary"))
+ session.execute(
+ text("REFRESH MATERIALIZED VIEW ogc_internal_water_well_summary")
+ )
+ session.commit()
+
+ group_a = Group(
+ name="Test Other Group A",
+ group_type="Monitoring Plan",
+ release_status="public",
+ )
+ group_b = Group(
+ name="Test Other Group B",
+ group_type="Monitoring Plan",
+ release_status="public",
+ )
+ session.add_all([group_a, group_b])
+ session.flush()
+
+ group_assoc_a = GroupThingAssociation(
+ group_id=group_a.id,
+ thing_id=water_well_thing.id,
+ )
+ group_assoc_b = GroupThingAssociation(
+ group_id=group_b.id,
+ thing_id=water_well_thing.id,
+ )
+ session.add_all([group_assoc_a, group_assoc_b])
+ status_history = StatusHistory(
+ status_type="Monitoring Status",
+ status_value="Currently monitored",
+ start_date=date(2024, 1, 1),
+ target_id=water_well_thing.id,
+ target_table="thing",
+ )
+ session.add(status_history)
+ session.commit()
+
+ row = session.execute(
+ text(
+ "SELECT group_ids, group_names, group_types "
+ "FROM ogc_actively_monitored_wells WHERE id = :thing_id"
+ ),
+ {"thing_id": water_well_thing.id},
+ ).one()
+
+ assert set(row.group_ids) == {group_a.id, group_b.id}
+ assert set(row.group_names) == {"Test Other Group A", "Test Other Group B"}
+ assert row.group_types == ["Monitoring Plan", "Monitoring Plan"]
+
+ internal_row = session.execute(
+ text(
+ "SELECT group_ids, group_names, group_types "
+ "FROM ogc_internal_actively_monitored_wells WHERE id = :thing_id"
+ ),
+ {"thing_id": water_well_thing.id},
+ ).one()
+
+ assert set(internal_row.group_ids) == {group_a.id, group_b.id}
+ assert set(internal_row.group_names) == {
+ "Test Other Group A",
+ "Test Other Group B",
+ }
+ assert internal_row.group_types == ["Monitoring Plan", "Monitoring Plan"]
+
+ session.delete(status_history)
+ session.delete(group_assoc_a)
+ session.delete(group_assoc_b)
+ session.delete(group_a)
+ session.delete(group_b)
+ session.commit()
+
+
+def test_ogc_actively_monitored_wells_hides_draft_group_on_public_view(
+ water_well_thing,
+ groundwater_level_observation,
+):
+ with session_ctx() as session:
+ session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_well_summary"))
+ session.execute(
+ text("REFRESH MATERIALIZED VIEW ogc_internal_water_well_summary")
+ )
+ session.commit()
+
+ group = Group(
+ name="Test Draft Group",
+ group_type="Monitoring Plan",
+ release_status="draft",
+ )
+ session.add(group)
+ session.flush()
+
+ group_assoc = GroupThingAssociation(
+ group_id=group.id,
+ thing_id=water_well_thing.id,
+ )
+ session.add(group_assoc)
+ status_history = StatusHistory(
+ status_type="Monitoring Status",
+ status_value="Currently monitored",
+ start_date=date(2024, 1, 1),
+ target_id=water_well_thing.id,
+ target_table="thing",
+ )
+ session.add(status_history)
+ session.commit()
+
+ public_row = session.execute(
+ text("SELECT id FROM ogc_actively_monitored_wells WHERE id = :thing_id"),
+ {"thing_id": water_well_thing.id},
+ ).one_or_none()
+ assert public_row is None
+
+ internal_row = session.execute(
+ text(
+ "SELECT group_ids FROM ogc_internal_actively_monitored_wells "
+ "WHERE id = :thing_id"
+ ),
+ {"thing_id": water_well_thing.id},
+ ).one()
+ assert internal_row.group_ids == [group.id]
+
+ session.delete(status_history)
+ session.delete(group_assoc)
+ session.delete(group)
+ session.commit()
+
+
def test_ogc_collections(ogc_client):
response = ogc_client.get("/ogcapi/collections")
assert response.status_code == 200
From 4cf10fa24b3e319535769101756c4e743610bcb4 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sat, 22 Aug 2026 15:47:31 -0700
Subject: [PATCH 140/151] docs(ogc): describe actively_monitored_wells as
all-groups
BDMS-974 drops the "group name = 'water level network'" predicate from
ogc_actively_monitored_wells, so the layer now covers currently-monitored
wells in any group. The published prose still described the old filter and
would have shipped wrong the moment that migration ran.
The collection description now keys the layer on the monitoring status
alone, and says what the join actually produces: a well in several groups
appears once per group. That last point matters enough to repeat at field
level -- the id column is no longer unique within the collection, which
is the kind of thing a client discovers by having its keyed-by-id map
silently drop rows. group_name loses its claim that it is always the
Water Level Network.
Keywords drop water-level-network for monitoring-status.
Co-Authored-By: Claude Opus 5
---
core/pygeoapi-config-internal.yml | 18 ++++++++++--------
core/pygeoapi-config.yml | 18 ++++++++++--------
2 files changed, 20 insertions(+), 16 deletions(-)
diff --git a/core/pygeoapi-config-internal.yml b/core/pygeoapi-config-internal.yml
index 578bc8daa..2396539e1 100644
--- a/core/pygeoapi-config-internal.yml
+++ b/core/pygeoapi-config-internal.yml
@@ -361,15 +361,17 @@ resources:
title: Actively Monitored Wells
description: >-
The wells being measured today, rather than every well ever recorded.
- A well appears here only if it belongs to the Water Level Network
- group and its most recent monitoring-status entry reads "Currently
- monitored"; the summary statistics attached to each one are the same
- water-level figures published in water_well_summary. Use it to see the
- live monitoring network -- where measurements are still being
- collected, and where coverage is thin.
+ A well appears here when its most recent monitoring-status entry reads
+ "Currently monitored", whichever monitoring group it belongs to; the
+ summary statistics attached to each one are the same water-level
+ figures published in water_well_summary. A well belonging to several
+ groups still appears once, with every membership listed in group_ids,
+ group_names and group_types. Use it to see the live monitoring network
+ -- where measurements are still being collected, and where coverage is
+ thin.
keywords: [
- water-wells, monitoring, water-level-network, actively-monitored,
- monitoring-network, groundwater-level
+ water-wells, monitoring, actively-monitored, monitoring-network,
+ monitoring-status, groundwater-level
]
extents:
spatial:
diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml
index 4dbf0c972..093cd8b84 100644
--- a/core/pygeoapi-config.yml
+++ b/core/pygeoapi-config.yml
@@ -271,15 +271,17 @@ resources:
title: Actively Monitored Wells
description: >-
The wells being measured today, rather than every well ever recorded.
- A well appears here only if it belongs to the Water Level Network
- group and its most recent monitoring-status entry reads "Currently
- monitored"; the summary statistics attached to each one are the same
- water-level figures published in water_well_summary. Use it to see the
- live monitoring network -- where measurements are still being
- collected, and where coverage is thin.
+ A well appears here when its most recent monitoring-status entry reads
+ "Currently monitored", whichever monitoring group it belongs to; the
+ summary statistics attached to each one are the same water-level
+ figures published in water_well_summary. A well belonging to several
+ groups still appears once, with every membership listed in group_ids,
+ group_names and group_types. Use it to see the live monitoring network
+ -- where measurements are still being collected, and where coverage is
+ thin.
keywords: [
- water-wells, monitoring, water-level-network, actively-monitored,
- monitoring-network, groundwater-level
+ water-wells, monitoring, actively-monitored, monitoring-network,
+ monitoring-status, groundwater-level
]
extents:
spatial:
From 02f0fe50f4825c2b46580a2ff9dfe8acb4388d35 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sat, 22 Aug 2026 17:20:53 -0700
Subject: [PATCH 141/151] fix(edr): implement pygeoapi's instance contract
/ogcapi/collections/waterlevels/instances returned a 500:
TypeError: 'NotImplementedError' object is not iterable
pygeoapi calls p.instances() and p.instance(id); this provider spelled
them get_instances() and get_instance(). Because BaseEDRProvider
*returns* a NotImplementedError instance from both rather than raising
one, the mismatch was silent -- /instances iterated that object, and
/instances/{id}/... validated the id against a truthy object, so any
identifier at all was accepted. Transducer deployments have therefore
never been reachable as EDR instances, which is exactly what the
waterlevels description advertises. Renamed; nothing called the old
names.
The behave feature that covers this has existed since ADR3 and caught it
on the first run, but it is tagged @backend @edr with no @production, and
CI runs "@backend and @production and not @skip" -- so it has never run
there. Tagged @production.
Two fixture defects were hiding behind that, both of which made the
chemistry scenarios fail once the feature ran:
* The fixture seeded chemistry as observation rows, but d9e0f1a2b3c4
rebuilt ogc_water_chemistry over the legacy NMA_* tables, so the
collection saw nothing -- a 400 (pH is not a known parameter) and a
204. It now seeds NMA_Chemistry_SampleInfo/NMA_FieldParameters and
refreshes the materialized view, without which the rows stay invisible
anyway.
* ogc_water_chemistry gates on the thing's release_status as well as the
sample's, and wells seed as 'draft', so the fixture published no
chemistry at all. It now promotes its own well to public.
Full production behave suite: 85 scenarios, 0 failed.
Co-Authored-By: Claude Opus 5
---
core/edr_provider.py | 18 ++++++++---
tests/features/edr-water-data.feature | 2 +-
tests/features/environment.py | 40 ++++++++++++++++++++++++
tests/test_edr_provider.py | 45 +++++++++++++++++++++++++++
4 files changed, 100 insertions(+), 5 deletions(-)
diff --git a/core/edr_provider.py b/core/edr_provider.py
index eba54880d..95af6a79d 100644
--- a/core/edr_provider.py
+++ b/core/edr_provider.py
@@ -181,8 +181,18 @@ def fields(self):
return self.get_fields()
# ----------------------------------------------------------- instances
- def get_instances(self):
- """List transducer-deployment instance identifiers."""
+ def instances(self):
+ """List transducer-deployment instance identifiers.
+
+ Named for pygeoapi's EDR contract, not ours: ``get_collection_edr_
+ instances`` calls ``p.instances()`` and ``p.instance(id)``, and
+ ``BaseEDRProvider`` *returns* (rather than raises) a
+ ``NotImplementedError`` instance from both. A provider that spells
+ these ``get_instances``/``get_instance`` therefore does not override
+ anything -- /instances iterates the NotImplementedError object and
+ 500s, and /instances/{id}/... validates against a truthy object, so
+ any id at all is accepted.
+ """
if not self.instance_field:
return []
rows = self._fetch(
@@ -192,9 +202,9 @@ def get_instances(self):
)
return [str(row["iid"]) for row in rows]
- def get_instance(self, instance):
+ def instance(self, instance):
"""Validate an instance identifier."""
- return instance in set(self.get_instances())
+ return str(instance) in set(self.instances())
# ------------------------------------------------------------ queries
def locations(
diff --git a/tests/features/edr-water-data.feature b/tests/features/edr-water-data.feature
index 9e21f56b9..08cd063f8 100644
--- a/tests/features/edr-water-data.feature
+++ b/tests/features/edr-water-data.feature
@@ -1,4 +1,4 @@
-@backend @edr
+@backend @edr @production
Feature: OGC API - EDR delivery of water-level and water-chemistry data
As a consumer of Bureau observational data
I want to query groundwater levels and water chemistry through the standard
diff --git a/tests/features/environment.py b/tests/features/environment.py
index 4d0f69034..2a7af12dc 100644
--- a/tests/features/environment.py
+++ b/tests/features/environment.py
@@ -527,6 +527,15 @@ def add_edr_water_data(context, session, well, deployment):
lex_term = "(SELECT term FROM lexicon_term LIMIT 1)"
+ # Wells seed as 'draft', but ogc_water_chemistry gates on the *thing's*
+ # release status as well as the sample's, so a draft well publishes no
+ # chemistry at all. Promote this one well -- the fixture exists to give
+ # the EDR collections something to serve.
+ session.execute(
+ text("UPDATE thing SET release_status = 'public' WHERE id = :tid"),
+ {"tid": well.id},
+ )
+
# Promote the seeded transducer data to public and give the deployment a
# bounded window + recording interval so it reads as an EDR instance.
session.execute(
@@ -595,6 +604,37 @@ def add_edr_water_data(context, session, well, deployment):
{"sid": sid, "pid": pid, "dt": dt, "val": value, "st": status},
)
+ # ogc_water_chemistry is built from the legacy NMA_* chemistry tables
+ # (d9e0f1a2b3c4), not from observation: nothing populates the
+ # observation -> sample -> parameter chain with analyte data. Seeding
+ # only observations left the EDR chemistry collection empty, which is
+ # why its scenarios failed with 400 (pH not a known parameter) and 204.
+ for public_release, ph_value in ((True, 7.1), (False, 99.0)):
+ sample_info_id = session.execute(
+ text(
+ 'INSERT INTO "NMA_Chemistry_SampleInfo" '
+ '(thing_id, "CollectionDate", "PublicRelease", '
+ '"nma_SamplePointID") '
+ "VALUES (:tid, '2022-06-01T00:00:00Z', :pub, 'EDR-TEST') "
+ "RETURNING id"
+ ),
+ {"tid": well.id, "pub": public_release},
+ ).scalar()
+ session.execute(
+ text(
+ 'INSERT INTO "NMA_FieldParameters" '
+ '(chemistry_sample_info_id, "FieldParameter", "SampleValue", '
+ "\"Units\") VALUES (:csi, 'pH', :val, 'std units')"
+ ),
+ {"csi": sample_info_id, "val": ph_value},
+ )
+
+ session.commit()
+
+ # Materialized view: without a refresh the rows just inserted are
+ # invisible to every chemistry query.
+ session.execute(text("REFRESH MATERIALIZED VIEW ogc_water_chemistry"))
+ session.execute(text("REFRESH MATERIALIZED VIEW ogc_internal_water_chemistry"))
session.commit()
diff --git a/tests/test_edr_provider.py b/tests/test_edr_provider.py
index f7ba87dcc..9f60fa4cd 100644
--- a/tests/test_edr_provider.py
+++ b/tests/test_edr_provider.py
@@ -30,3 +30,48 @@ def test_station_properties_omits_thing_type_when_the_view_lacks_it():
properties = _provider(False)._station_properties({"station_name": "NM-28368"})
assert properties == {"name": "NM-28368"}
+
+
+# ---------------------------------------------------------------- instances
+
+
+def test_provider_implements_pygeoapis_instance_contract():
+ """The method names pygeoapi actually calls.
+
+ BaseEDRProvider.instances/instance *return* a NotImplementedError rather
+ than raising one, so a provider that spells these get_instances/
+ get_instance overrides nothing and fails silently at the API layer:
+ /instances iterates the NotImplementedError object (TypeError -> 500), and
+ /instances/{id}/... validates the id against a truthy object, accepting
+ anything.
+ """
+ from pygeoapi.provider.base_edr import BaseEDRProvider
+
+ for name in ("instances", "instance"):
+ assert name in WaterEDRProvider.__dict__, (
+ f"WaterEDRProvider must override {name}() -- pygeoapi calls that "
+ "name, and the base implementation returns a NotImplementedError "
+ "object instead of raising."
+ )
+ assert getattr(WaterEDRProvider, name) is not getattr(BaseEDRProvider, name)
+
+
+def test_instances_are_empty_without_an_instance_field():
+ # ogc_water_chemistry has no deployments, so its provider declares no
+ # instance_field and must report an empty list rather than querying.
+ provider = object.__new__(WaterEDRProvider)
+ provider.instance_field = None
+
+ assert provider.instances() == []
+
+
+def test_instance_validation_compares_as_strings(monkeypatch):
+ # instances() reports identifiers as strings; the id arrives from the URL
+ # as a string too, but an int must not slip through as valid.
+ provider = object.__new__(WaterEDRProvider)
+ provider.instance_field = "deployment_id"
+ monkeypatch.setattr(WaterEDRProvider, "instances", lambda self: ["7", "9"])
+
+ assert provider.instance("7") is True
+ assert provider.instance(7) is True
+ assert provider.instance("8") is False
From c47f4813a7a4e47aad9b149a70631ec727ee8b1c Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sat, 22 Aug 2026 17:38:38 -0700
Subject: [PATCH 142/151] fix(ogc): gate ogc_waterlevels on the well's release
status
ogc_waterlevels filtered on the reading's own release_status only, so a
well whose own release_status was 'draft' or 'private' still published
its public readings through OGC API - EDR -- with the well's name and
coordinates attached to every one of them. ogc_water_chemistry has
required the parent thing to be public since d9e0f1a2b3c4; this brings
water levels onto the same rule, which is what made the inconsistency
visible in the first place.
ogc_internal_waterlevels is deliberately untouched. It carries non-public
records by design for authenticated staff clients, exactly as
ogc_internal_water_chemistry does.
Downgrade restores the definition by importing z9a0b1c2d3e4 rather than
copying its SQL, so the reverted view cannot drift from the definition of
record -- the same approach b7c8d9e0f1a2 took.
No rows change on the dev database: it has no non-public well carrying
readings today, so this closes the hole rather than retracting published
data. Whether that also holds in production is worth checking before
deploy.
Co-Authored-By: Claude Opus 5
---
...3_gate_ogc_waterlevels_on_thing_release.py | 122 ++++++++++++++++++
tests/test_ogc.py | 51 ++++++++
2 files changed, 173 insertions(+)
create mode 100644 alembic/versions/baba91fe5e83_gate_ogc_waterlevels_on_thing_release.py
diff --git a/alembic/versions/baba91fe5e83_gate_ogc_waterlevels_on_thing_release.py b/alembic/versions/baba91fe5e83_gate_ogc_waterlevels_on_thing_release.py
new file mode 100644
index 000000000..2eee5c2a5
--- /dev/null
+++ b/alembic/versions/baba91fe5e83_gate_ogc_waterlevels_on_thing_release.py
@@ -0,0 +1,122 @@
+"""gate ogc_waterlevels on the thing's release status
+
+ogc_waterlevels filtered on the reading's own release_status only, so a well
+whose release_status is 'draft' or 'private' still published its public
+readings through OGC API - EDR -- with the well's name and coordinates
+attached. ogc_water_chemistry (d9e0f1a2b3c4) already required the parent thing
+to be public; this brings water levels onto the same rule.
+
+The internal mirror, ogc_internal_waterlevels, is deliberately left alone: it
+carries non-public records by design for authenticated staff clients, the same
+way ogc_internal_water_chemistry does.
+
+Revision ID: baba91fe5e83
+Revises: 986e0eb85ab3
+Create Date: 2026-08-22 18:35:00.000000
+
+"""
+
+import importlib.util
+from pathlib import Path
+from typing import Sequence, Union
+
+from alembic import op
+from sqlalchemy import text
+
+# revision identifiers, used by Alembic.
+revision: str = "baba91fe5e83"
+down_revision: Union[str, Sequence[str], None] = "986e0eb85ab3"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+_ORIGINAL_REVISION = "z9a0b1c2d3e4_add_edr_water_views.py"
+
+# Shared join from a thing to its current location point. Mirrors the join in
+# z9a0b1c2d3e4, which is the definition of record for this view.
+_LOCATION_JOIN = """
+ JOIN location_thing_association lta
+ ON lta.thing_id = t.id AND lta.effective_end IS NULL
+ JOIN location l ON l.id = lta.location_id
+"""
+
+
+def _load_original_module():
+ """Import z9a0b1c2d3e4 so downgrade restores its SQL rather than a copy."""
+ path = Path(__file__).with_name(_ORIGINAL_REVISION)
+ if not path.exists():
+ raise RuntimeError(
+ "Cannot restore the previous ogc_waterlevels definition: "
+ f"{_ORIGINAL_REVISION} is missing from alembic/versions."
+ )
+ spec = importlib.util.spec_from_file_location("_edr_water_views", path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _create_waterlevels_view() -> str:
+ return f"""
+ CREATE VIEW ogc_waterlevels AS
+ -- manual water-level readings
+ SELECT
+ 'm-' || o.id AS id,
+ t.id AS thing_id,
+ t.name AS station_name,
+ ST_X(l.point) AS longitude,
+ ST_Y(l.point) AS latitude,
+ o.observation_datetime AS datetime,
+ o.value AS value,
+ o.unit AS unit,
+ 'groundwater level' AS parameter_name,
+ 'manual' AS source,
+ NULL::integer AS deployment_id,
+ o.release_status AS release_status
+ FROM observation o
+ JOIN parameter p
+ ON p.id = o.parameter_id AND p.parameter_name = 'groundwater level'
+ JOIN sample sm ON sm.id = o.sample_id
+ JOIN field_activity fa ON fa.id = sm.field_activity_id
+ JOIN field_event fe ON fe.id = fa.field_event_id
+ JOIN thing t ON t.id = fe.thing_id
+ {_LOCATION_JOIN}
+ WHERE o.release_status = 'public'
+ AND t.release_status = 'public'
+ AND o.value IS NOT NULL
+
+ UNION ALL
+
+ -- transducer (instrument) water-level readings
+ SELECT
+ 't-' || tobs.id AS id,
+ t.id AS thing_id,
+ t.name AS station_name,
+ ST_X(l.point) AS longitude,
+ ST_Y(l.point) AS latitude,
+ tobs.observation_datetime AS datetime,
+ tobs.value AS value,
+ p.default_unit AS unit,
+ 'groundwater level' AS parameter_name,
+ 'transducer' AS source,
+ tobs.deployment_id AS deployment_id,
+ tobs.release_status AS release_status
+ FROM transducer_observation tobs
+ JOIN parameter p
+ ON p.id = tobs.parameter_id AND p.parameter_name = 'groundwater level'
+ JOIN deployment d ON d.id = tobs.deployment_id
+ JOIN thing t ON t.id = d.thing_id
+ {_LOCATION_JOIN}
+ WHERE tobs.release_status = 'public'
+ AND t.release_status = 'public'
+ AND tobs.value IS NOT NULL
+ """
+
+
+def upgrade() -> None:
+ op.execute(text("DROP VIEW IF EXISTS ogc_waterlevels"))
+ op.execute(text(_create_waterlevels_view()))
+
+
+def downgrade() -> None:
+ original = _load_original_module()
+ op.execute(text("DROP VIEW IF EXISTS ogc_waterlevels"))
+ op.execute(text(original._create_waterlevels_view()))
diff --git a/tests/test_ogc.py b/tests/test_ogc.py
index 35ed69ac6..86265e5a8 100644
--- a/tests/test_ogc.py
+++ b/tests/test_ogc.py
@@ -779,3 +779,54 @@ def test_ogc_polygon_within_filter(location):
assert response.status_code == 200
payload = response.json()
assert payload["numberReturned"] >= 1
+
+
+def test_ogc_waterlevels_excludes_readings_from_a_non_public_well(
+ water_well_thing, groundwater_level_observation
+):
+ """A well that is not public publishes no water levels, even public ones.
+
+ ogc_waterlevels used to filter on the reading's own release_status alone,
+ so a draft or private well still published its public readings -- with the
+ well's name and coordinates attached. ogc_water_chemistry already required
+ the parent thing to be public; baba91fe5e83 brought water levels onto the
+ same rule.
+ """
+ with session_ctx() as session:
+ session.execute(
+ text("UPDATE observation SET release_status = 'public' WHERE id = :oid"),
+ {"oid": groundwater_level_observation.id},
+ )
+ session.execute(
+ text("UPDATE thing SET release_status = 'draft' WHERE id = :tid"),
+ {"tid": water_well_thing.id},
+ )
+ session.commit()
+
+ reading_id = f"m-{groundwater_level_observation.id}"
+
+ public_rows = session.execute(
+ text("SELECT id FROM ogc_waterlevels WHERE id = :rid"),
+ {"rid": reading_id},
+ ).all()
+ assert not public_rows, "a non-public well leaked a reading to /ogcapi"
+
+ # The internal mount is where staff see non-public records; it must
+ # keep carrying them.
+ internal_rows = session.execute(
+ text("SELECT id FROM ogc_internal_waterlevels WHERE id = :rid"),
+ {"rid": reading_id},
+ ).all()
+ assert internal_rows, "the internal mirror stopped carrying the reading"
+
+ # Public well, public reading: published again.
+ session.execute(
+ text("UPDATE thing SET release_status = 'public' WHERE id = :tid"),
+ {"tid": water_well_thing.id},
+ )
+ session.commit()
+
+ assert session.execute(
+ text("SELECT id FROM ogc_waterlevels WHERE id = :rid"),
+ {"rid": reading_id},
+ ).all()
From 04fafcea29ff5278ac224eff4f9dc7181e5000e0 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sun, 23 Aug 2026 03:02:26 -0700
Subject: [PATCH 143/151] feat(gis): generate shareable QGIS and ArcGIS Pro
artifacts
Serves downloadable connection and layer files from /gis so a desktop GIS user
reaches our OGC API - Features collections without configuring a connection by
hand. Two levels per client: a connections file that registers the whole
service in one import, and six curated layer files carrying symbology, field
aliases, value maps and scale visibility.
Generated rather than committed because every artifact embeds an absolute
service URL, and there are three environments times two mounts. Static files
would mean six copies of each, all going stale as collections are added --
production already advertises 30 against the 13 defined in core/pygeoapi.py.
The base URL comes from _server_url(), the same value pygeoapi stamps into its
own self/next links, so a client that imports a connection and then pages
through items never crosses hosts.
Aliases and value maps derive from core/ogc-field-descriptions.yml, the file
that already feeds /schema and /queryables, so a renamed field cannot drift
between the API and the shipped layer files. Value maps are emitted only where
the label differs from the stored value: the lexicon columns store terms that
already read as prose, and mapping them to themselves would add kilobytes of
noise per layer. trend_category is the real case -- "increasing" means the
water table is falling.
_defaults in that file is a shared pool, not a set of universal columns; it
carries well and geothermal fields side by side and describe_fields only
applies the ones a view reflects. collection_fields() does the same
intersection here, because unfiltered a nine-column collection ships aliases
for 42 fields. QGIS drops what it cannot match; ArcGIS Pro would not.
No artifact embeds a credential. Both formats allow it -- QGIS connections have
username/password attributes, CIMInternetServerConnection has a user field --
but internal access uses per-user API keys so they can be revoked per user, and
a shared file carrying one person's key defeats that.
The two EDR collections are excluded: they publish no /items endpoint and
neither client has an EDR reader, so such a layer file would not open. The
curated water-level layers use the feature collections carrying the same
measurements summarised per site.
The ArcGIS .ogc connection file is NOT generated. Esri documents where Pro
writes it but not what is in it, and it is absent from the CIM spec, so /gis
gives the two-step click path instead of shipping a guess that fails in the one
client we cannot test against.
Verification: the .qlr format was established by loading every curated layer
into a real QGIS 4.0.1 against live production -- all six load valid on the
OAPIF provider, serve live features and apply renderer, aliases, value map and
scale. Two findings are pinned by tests: a flattened value map segfaults QGIS
rather than erroring, and a curated layer must name a collection this branch
serves (the first draft of the water-level layer named one only production
has). The .lyrx files follow Esri's published CIM spec but have not been opened
in Pro; none is available here.
uv run pytest --ignore=tests/transfers -> 1140 passed, 84 skipped, 6 xpassed
Co-Authored-By: Claude Opus 5
---
api/gis_artifacts.py | 179 +++++++++
core/gis-curated-layers.yml | 116 ++++++
core/initializers.py | 2 +
docs/ogc-desktop-gis-artifacts.md | 176 +++++++++
services/gis_artifacts.py | 625 ++++++++++++++++++++++++++++++
tests/test_authorization.py | 6 +
tests/test_gis_artifacts.py | 321 +++++++++++++++
7 files changed, 1425 insertions(+)
create mode 100644 api/gis_artifacts.py
create mode 100644 core/gis-curated-layers.yml
create mode 100644 docs/ogc-desktop-gis-artifacts.md
create mode 100644 services/gis_artifacts.py
create mode 100644 tests/test_gis_artifacts.py
diff --git a/api/gis_artifacts.py b/api/gis_artifacts.py
new file mode 100644
index 000000000..915fd2d87
--- /dev/null
+++ b/api/gis_artifacts.py
@@ -0,0 +1,179 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Downloadable QGIS and ArcGIS Pro artifacts for the OGC API mounts.
+
+The public routes are deliberately anonymous: they describe the public
+`/ogcapi` mount, which is itself anonymous, and a desktop GIS user fetching a
+connection file has no credential to present. Nothing they return is
+sensitive -- the URLs are already advertised in the pygeoapi landing page, and
+no credential is ever embedded (see services/gis_artifacts).
+
+The internal connection file is gated, not because the file is secret, but
+because the internal mount's existence is not something to advertise to
+anonymous callers. Holding it still gets you nothing without an `OGCInternal`
+API key.
+
+Read docs/ogc-desktop-gis-artifacts.md before changing what is emitted.
+"""
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import HTMLResponse, PlainTextResponse, Response
+
+from core.app import in_public_schema
+from core.dependencies import session_dependency, viewer_dependency
+from core.pygeoapi import _internal_server_url, _server_url
+from services.gis_artifacts import (
+ Connection,
+ arcgis_layer_file,
+ collection_fields,
+ find_curated_layer,
+ load_curated_layers,
+ qgis_connections_xml,
+ qgis_layer_definition,
+)
+
+router = APIRouter(prefix="/gis", tags=["desktop gis"])
+
+PUBLIC_CONNECTION_NAME = "NMBGMR Ocotillo"
+INTERNAL_CONNECTION_NAME = "NMBGMR Ocotillo (internal)"
+
+
+def _attachment(body: str, media_type: str, filename: str) -> Response:
+ return Response(
+ content=body,
+ media_type=media_type,
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
+def _public_base() -> str:
+ # _server_url() is what pygeoapi stamps into its own `self`/`next` links.
+ # Deriving the artifact's URL from the same place means a client that
+ # imports the connection and then pages through `items` never crosses
+ # hosts -- the failure mode that PYGEOAPI_INTERNAL_SERVER_URL was added to
+ # fix (see the comment in core/pygeoapi._internal_server_url).
+ return _server_url()
+
+
+@router.get("/qgis/connections.xml", response_class=PlainTextResponse)
+@in_public_schema
+def qgis_connections() -> Response:
+ """QGIS connections file registering the public OGC API - Features mount.
+
+ Import through **Browser panel > right-click "WFS / OGC API - Features" >
+ Load Connections**.
+ """
+ body = qgis_connections_xml([Connection(PUBLIC_CONNECTION_NAME, _public_base())])
+ return _attachment(body, "text/xml", "ocotillo-ogcapi-connections.xml")
+
+
+@router.get("/qgis/connections-internal.xml", response_class=PlainTextResponse)
+def qgis_connections_internal(user: viewer_dependency) -> Response:
+ """QGIS connections file covering the public and internal mounts.
+
+ Carries no credential. The internal entry only resolves for a client that
+ attaches its own `OGCInternal` API key -- see
+ docs/internal-ogc-desktop-gis.md for how one is issued and attached.
+ """
+ body = qgis_connections_xml(
+ [
+ Connection(PUBLIC_CONNECTION_NAME, _public_base()),
+ Connection(INTERNAL_CONNECTION_NAME, _internal_server_url()),
+ ]
+ )
+ return _attachment(body, "text/xml", "ocotillo-ogcapi-connections-internal.xml")
+
+
+@router.get("/qgis/layers/{layer_id}.qlr", response_class=PlainTextResponse)
+@in_public_schema
+def qgis_layer(layer_id: str, session: session_dependency) -> Response:
+ """A styled QGIS layer definition for one curated layer."""
+ layer = find_curated_layer(layer_id)
+ if layer is None:
+ raise HTTPException(status_code=404, detail=f"No curated layer {layer_id!r}.")
+ fields = collection_fields(session, layer.collection)
+ body = qgis_layer_definition(layer, _public_base(), fields)
+ return _attachment(body, "text/xml", f"{layer_id}.qlr")
+
+
+@router.get("/arcgis/layers/{layer_id}.lyrx", response_class=PlainTextResponse)
+@in_public_schema
+def arcgis_layer(layer_id: str, session: session_dependency) -> Response:
+ """A styled ArcGIS Pro layer file for one curated layer."""
+ layer = find_curated_layer(layer_id)
+ if layer is None:
+ raise HTTPException(status_code=404, detail=f"No curated layer {layer_id!r}.")
+ fields = collection_fields(session, layer.collection)
+ body = arcgis_layer_file(layer, _public_base(), fields)
+ return _attachment(body, "application/json", f"{layer_id}.lyrx")
+
+
+_PAGE_STYLE = (
+ "max-width:52rem;margin:3rem auto;padding:0 1.25rem;"
+ "font-family:system-ui,-apple-system,'Segoe UI',sans-serif;"
+ "line-height:1.6;color:#1a1a1a"
+)
+
+
+@router.get("", response_class=HTMLResponse)
+@in_public_schema
+def gis_index() -> HTMLResponse:
+ """Landing page listing every downloadable artifact."""
+ base = _public_base()
+ rows = "".join(
+ f"{layer.title} "
+ f"{layer.abstract} "
+ f'.qlr '
+ f'.lyrx '
+ for layer in load_curated_layers()
+ )
+ return HTMLResponse(
+ f"""
+Desktop GIS downloads
+
+Using our OGC layers in QGIS and ArcGIS Pro
+Service URL: {base}
+
+Everything at once
+QGIS connections file —
+in QGIS, open the Browser panel, right-click
+WFS / OGC API - Features , choose Load Connections , and pick
+this file. Every collection then appears in the Browser panel.
+ArcGIS Pro — Pro writes its own .ogc
+connection file and we cannot generate one for you. Add the connection once:
+Insert > Connections > Server > New OGC API Server , and paste
+the service URL above. Pro saves a .ogc file into your project
+folder that you can then share with colleagues.
+
+One layer at a time
+Styled, with field aliases already applied. Drag the file into QGIS, or add
+the .lyrx to a map in Pro.
+
+Layer QGIS ArcGIS Pro
+{rows}
+
+
+Time series
+Water levels and water chemistry are also published as
+OGC API - EDR time series at {base}/collections/waterlevels and
+{base}/collections/water_chemistry. Neither QGIS nor ArcGIS Pro
+can read EDR, so the layers above carry the same measurements summarised per
+site instead.
+"""
+ )
+
+
+# ============= EOF =============================================
diff --git a/core/gis-curated-layers.yml b/core/gis-curated-layers.yml
new file mode 100644
index 000000000..372df17bb
--- /dev/null
+++ b/core/gis-curated-layers.yml
@@ -0,0 +1,116 @@
+# Curated desktop-GIS layers.
+#
+# Each entry becomes one QGIS .qlr and one ArcGIS Pro .lyrx. These are the
+# "I just want water levels" artifacts -- a small, opinionated set, not a
+# mirror of the collection list. The connection files cover "give me
+# everything"; anything a user can reach by browsing the connection does not
+# need an entry here.
+#
+# `collection` must name a collection served by the OGC API - Features mount.
+# The two EDR collections (waterlevels, water_chemistry) cannot appear here:
+# neither QGIS nor ArcGIS Pro has an OGC API - EDR client, so a layer file
+# pointing at one would not open. The feature collections below carry the same
+# measurements summarised per site, which is what a GIS user wants on a map.
+#
+# Field aliases and value maps are NOT listed here. They are derived from
+# core/ogc-field-descriptions.yml, the same file that feeds /schema and
+# /queryables, so a renamed field cannot drift between the API and the shipped
+# layer files.
+#
+# Colours are chosen to stay distinguishable for the common forms of colour
+# blindness: the sequential ramps run light-to-dark so they survive being read
+# by lightness alone, and the trend categories pair hue with a size difference.
+#
+# See docs/ogc-desktop-gis-artifacts.md.
+
+layers:
+ - id: water-wells
+ collection: water_wells
+ title: Water Wells
+ abstract: >-
+ Every groundwater well in the monitoring-point register, at its most
+ recent recorded location.
+ geometry: Point
+ renderer:
+ type: single
+ color: "31,119,180,255"
+ size: 2.2
+ outline_color: "255,255,255,200"
+
+ - id: depth-to-water
+ collection: water_elevation_wells
+ title: Depth to Water
+ abstract: >-
+ Depth to the water table at each well at its most recent measurement, in
+ feet below ground surface. Larger values mean a deeper water table.
+ geometry: Point
+ renderer:
+ type: graduated
+ field: depth_to_water_below_ground_surface_ft
+ size: 2.6
+ classes:
+ - {lower: 0, upper: 25, label: "0 - 25 ft", color: "237,248,251,255"}
+ - {lower: 25, upper: 50, label: "25 - 50 ft", color: "179,205,227,255"}
+ - {lower: 50, upper: 100, label: "50 - 100 ft", color: "140,150,198,255"}
+ - {lower: 100, upper: 250, label: "100 - 250 ft", color: "136,86,167,255"}
+ - {lower: 250, upper: 100000, label: "over 250 ft", color: "129,15,124,255"}
+
+ - id: water-level-trend
+ collection: depth_to_water_trend_wells
+ title: Water-Level Trend
+ abstract: >-
+ Direction of the fitted depth-to-water trend at each well. "Falling
+ water table" means depth below ground surface is increasing.
+ geometry: Point
+ renderer:
+ type: categorized
+ field: trend_category
+ size: 2.6
+ categories:
+ - {value: "increasing", label: "Falling water table", color: "202,58,48,255", size: 3.2}
+ - {value: "decreasing", label: "Rising water table", color: "42,122,182,255", size: 3.2}
+ - {value: "stable", label: "Stable", color: "140,140,140,255", size: 2.2}
+ - {value: "not enough data", label: "Not enough data", color: "225,225,225,255", size: 1.8}
+
+ - id: actively-monitored-wells
+ collection: actively_monitored_wells
+ title: Actively Monitored Wells
+ abstract: >-
+ Wells currently on a monitoring schedule, with their water-level record
+ summarised.
+ geometry: Point
+ renderer:
+ type: single
+ color: "44,140,80,255"
+ size: 2.8
+ outline_color: "255,255,255,200"
+
+ - id: springs
+ collection: springs
+ title: Springs
+ abstract: Natural groundwater discharge points in the register.
+ geometry: Point
+ renderer:
+ type: single
+ color: "23,150,140,255"
+ size: 2.6
+ shape: triangle
+ outline_color: "255,255,255,200"
+
+ - id: latest-tds
+ collection: latest_tds_wells
+ title: Latest Total Dissolved Solids
+ abstract: >-
+ Most recent total-dissolved-solids result at each well. 1000 mg/L is the
+ conventional fresh/brackish boundary.
+ geometry: Point
+ renderer:
+ type: graduated
+ field: latest_tds_value
+ size: 2.6
+ classes:
+ - {lower: 0, upper: 500, label: "0 – 500 mg/L", color: "255,255,204,255"}
+ - {lower: 500, upper: 1000, label: "500 – 1000 mg/L", color: "161,218,180,255"}
+ - {lower: 1000, upper: 3000, label: "1000 – 3000 mg/L", color: "65,182,196,255"}
+ - {lower: 3000, upper: 10000, label: "3000 – 10000 mg/L", color: "44,127,184,255"}
+ - {lower: 10000, upper: 10000000, label: "over 10000 mg/L", color: "37,52,148,255"}
diff --git a/core/initializers.py b/core/initializers.py
index 01ef37230..9f419caa2 100644
--- a/core/initializers.py
+++ b/core/initializers.py
@@ -226,6 +226,7 @@ def register_api_routes(app):
from api.disclaimer import router as disclaimer_router
from api.geothermal import router as geothermal_router
from api.chemisty import router as chemistry_router
+ from api.gis_artifacts import router as gis_artifacts_router
app.include_router(asset_router)
app.include_router(chemistry_router)
@@ -233,6 +234,7 @@ def register_api_routes(app):
app.include_router(contact_router)
app.include_router(disclaimer_router)
app.include_router(geospatial_router)
+ app.include_router(gis_artifacts_router)
app.include_router(group_router)
app.include_router(lexicon_router)
app.include_router(location_router)
diff --git a/docs/ogc-desktop-gis-artifacts.md b/docs/ogc-desktop-gis-artifacts.md
new file mode 100644
index 000000000..3ab94a2dc
--- /dev/null
+++ b/docs/ogc-desktop-gis-artifacts.md
@@ -0,0 +1,176 @@
+# Shareable QGIS and ArcGIS Pro artifacts
+
+Downloadable files that get a desktop GIS user onto our OGC API - Features
+collections without them configuring a connection by hand.
+
+Landing page: **`/gis`**. Code:
+[`services/gis_artifacts.py`](../services/gis_artifacts.py),
+[`api/gis_artifacts.py`](../api/gis_artifacts.py), curated list in
+[`core/gis-curated-layers.yml`](../core/gis-curated-layers.yml).
+
+For connecting to the authenticated `/ogcapi-internal` mount, and for how API
+keys are issued, see
+[`internal-ogc-desktop-gis.md`](internal-ogc-desktop-gis.md).
+
+## Two levels, per client
+
+| | QGIS | ArcGIS Pro |
+|---|---|---|
+| Everything | `.xml` connections file — **generated** | `.ogc` connection file — **not generated**, see below |
+| One layer | `.qlr` layer definition — **generated** | `.lyrx` layer file — **generated** |
+
+```
+GET /gis landing page, links to everything
+GET /gis/qgis/connections.xml public mount
+GET /gis/qgis/connections-internal.xml public + internal (viewer role)
+GET /gis/qgis/layers/{id}.qlr
+GET /gis/arcgis/layers/{id}.lyrx
+```
+
+## Why these are generated rather than committed
+
+Every artifact embeds an absolute service URL, and there are three environments
+(production, staging, local) times two mounts. Committing static files means
+six copies of each, and each goes stale the moment a collection is added —
+production already advertises **30** collections against the 13 defined in
+`core/pygeoapi.py`. Generating from the running app means the URL is always the
+one the caller reached us on.
+
+The artifacts take their base URL from `core.pygeoapi._server_url()`, the same
+value pygeoapi stamps into its own `self` and `next` links. That is deliberate:
+both clients follow those links to page through `items`, so an artifact
+advertising a different host would work for one page and then walk off
+somewhere else — the failure `PYGEOAPI_INTERNAL_SERVER_URL` was added to fix.
+
+## The ArcGIS `.ogc` connection file is not generated
+
+Pro writes a `.ogc` file into the project home folder when you add an OGC API
+server connection, and that file is shareable — but **Esri does not document
+its format**. It is not in the CIM spec, and the Pro help describes only where
+the file lands, not what is in it. Rather than ship a guess that fails in the
+one client we cannot test against, `/gis` tells the user the two-step click
+path (*Insert > Connections > Server > New OGC API Server*, paste the URL) and
+lets Pro write its own file, which they can then share.
+
+To close this properly, someone with Pro should add the connection once and
+send back the resulting `.ogc`; templating it after that is a small change to
+`services/gis_artifacts.py`.
+
+## EDR is deliberately absent from the curated layers
+
+`waterlevels` and `water_chemistry` are served by the EDR provider only — they
+publish no `/items` endpoint. **Neither QGIS nor ArcGIS Pro has an OGC API - EDR
+client**, so a layer file pointing at either would not open. The curated
+"water levels" layer is `latest_depth_to_water_wells`, which carries the same
+measurement summarised per site, which is what a GIS user wants on a map.
+`test_no_curated_layer_points_at_an_edr_collection` enforces this.
+
+## The curated list is checked against *this branch*, not production
+
+Production advertises 30 collections; this branch defines 13 in
+`core/pygeoapi.py` plus 14 in the `core/pygeoapi-config.yml` template. A
+curated layer written against a live deployment can therefore name a
+collection that does not exist here, and the artifact 404s the moment a user
+opens it. `test_every_curated_layer_names_a_collection_this_branch_serves`
+reads both sources and fails on the mismatch. It caught exactly that during
+development: the first draft of the "water levels" layer pointed at
+`latest_depth_to_water_wells`, which only production serves. It now uses
+`water_elevation_wells`.
+
+## Aliases are intersected with the view's real columns
+
+`_defaults` in `core/ogc-field-descriptions.yml` is a **shared pool, not a set
+of universal columns** -- it carries well fields and geothermal fields side by
+side, and `describe_fields` only ever applies the ones a given view actually
+reflects. The generator must do the same intersection: unfiltered, a
+nine-column collection ships aliases for 42 fields. QGIS silently drops the
+ones it cannot match, but ArcGIS Pro takes `fieldDescriptions` at its word.
+
+`collection_fields()` reflects `ogc_` from `information_schema`
+and the routes pass the result through. It returns `None` when the view is
+absent -- a branch whose migrations have not created it yet -- and the caller
+then falls back to the full entry list rather than emitting a layer file with
+no aliases at all.
+
+## Field aliases and value maps are derived, not written twice
+
+Aliases come from `core/ogc-field-descriptions.yml` through
+`core.ogc_field_metadata.table_entries()` — the same file that feeds `/schema`
+and `/queryables`. A renamed or re-titled field therefore cannot drift between
+the API and the shipped layer files, and
+`test_qlr_aliases_cover_every_documented_field` fails if one does.
+
+Value maps are emitted **only where the display label differs from the stored
+value**, which in practice means the categorised renderer's own labels. The
+lexicon-backed columns (`thing_type`, `release_status`) already store terms
+that read as prose, so mapping them to themselves would add kilobytes of
+`name == value` noise per layer and give the user a dropdown that renames
+nothing. `trend_category` is the real case: `increasing` means the water table
+is *falling*.
+
+## No artifact ever embeds a credential
+
+QGIS's connection format has `username` and `password` attributes, and Esri's
+`CIMInternetServerConnection` has a `user` field, so embedding a key is
+possible in both formats. It is not done. Internal access uses per-user API
+keys precisely so they can be revoked per user; a shared file carrying one
+person's key defeats that. `connections-internal.xml` ships the internal URL
+credential-free and the user attaches their own key in their own client.
+`test_no_artifact_embeds_a_credential` guards this.
+
+Note that Esri's own spec marks the CIM connection password *"not persisted in
+documents"*, so `.lyrx` could not carry one even if we wanted it to.
+
+## Verification
+
+`tests/test_gis_artifacts.py` covers what can be checked without a GIS
+installed: XML/JSON well-formedness, the datasource URI, tree-node and maplayer
+agreement, renderer fields existing on the collection, alias coverage, the
+EDR exclusion, credential absence, and QGIS/ArcGIS renderer agreement.
+
+**That is not the same as the file opening.** The formats were established by
+loading them into a real QGIS 4.0.1 (`QgsLayerDefinition.loadLayerDefinition`)
+against the live production service, which confirmed for all six curated
+layers: the layer loads valid on the `OAPIF` provider, serves live features
+(2453 for the trend layer), and applies the renderer, every alias, the value
+map and scale visibility.
+
+Two findings from that exercise are worth keeping:
+
+- **A malformed value map segfaults QGIS 4.0.1 rather than erroring.** Each
+ entry must be its own `` wrapper whose single child is
+ named for the display label and carries the stored value. Flattening that
+ wrapper away crashes the process on load.
+ `test_qlr_value_map_entries_are_nested_option_maps` pins the shape.
+- The emitted `.qlr` is deliberately minimal — around 4–7 KB against the 27 KB
+ QGIS itself exports. QGIS fills every omitted element with its own defaults.
+
+QGIS is **not** a CI dependency, so re-run that check by hand when changing the
+emitted XML. The procedure needs a PyQGIS bootstrap; on macOS:
+
+```bash
+Q=/Applications/QGIS-final-4_0_1.app/Contents
+env PYTHONHOME="$Q/Frameworks" \
+ PYTHONPATH="$Q/Resources/python3.11/site-packages:$Q/Resources/python" \
+ PROJ_DATA="$Q/Resources/qgis/proj" QGIS_PREFIX="$Q/Resources" \
+ QT_QPA_PLATFORM=offscreen "$Q/MacOS/python3.12" your_check.py
+```
+
+`QgsProviderRegistry.instance("$Q/PlugIns/qgis")` must be called before
+`initQgis()` or no providers load and every layer comes back invalid. Reuse
+`QgsProject.instance()` and `clear()` between files — constructing more than
+one `QgsProject` segfaults.
+
+The `.lyrx` files have **not** been opened in ArcGIS Pro; none is available to
+this project. They are built to Esri's published CIM spec
+(`CIMLayerDocument` → `CIMFeatureLayer` → `CIMOGCAPIServiceConnection`). Treat
+the first open in Pro as the real test.
+
+## Adding a curated layer
+
+Add an entry to `core/gis-curated-layers.yml` with an `id`, a `collection` that
+the Features mount serves, a `title`, and a `renderer` of type `single`,
+`graduated` or `categorized`. The tests will fail if the renderer classifies on
+a field with no entry in `core/ogc-field-descriptions.yml`, if the collection
+is EDR-only, or if the id collides. Nothing else needs changing — the routes
+and the landing page read the config.
diff --git a/services/gis_artifacts.py b/services/gis_artifacts.py
new file mode 100644
index 000000000..7544dcfac
--- /dev/null
+++ b/services/gis_artifacts.py
@@ -0,0 +1,625 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Shareable QGIS and ArcGIS Pro artifacts for the OGC API - Features mounts.
+
+Two levels, per client:
+
+* a **connection file** that registers the whole service in one import, so the
+ user browses our collections in their Browser/Catalog panel;
+* a handful of **layer files** carrying symbology, field aliases, value maps
+ and scale visibility, for the user who only wants one curated view.
+
+WHY THESE ARE GENERATED, NOT COMMITTED
+--------------------------------------
+Every artifact embeds an absolute service URL, and there are three of them
+(production, staging, local) plus two mounts (`/ogcapi`, `/ogcapi-internal`).
+Committing static files would mean six copies of each, each of which goes stale
+the moment a collection is added -- and production already advertises 30
+collections against the 13 defined in ``core/pygeoapi.py``. Generating from the
+running app means the URL is always the one the caller reached us on.
+
+WHAT IS DERIVED, AND FROM WHERE
+-------------------------------
+* Field aliases and value maps come from ``core/ogc-field-descriptions.yml``
+ via ``core.ogc_field_metadata`` -- the same file that feeds ``/schema`` and
+ ``/queryables``. A renamed field therefore cannot drift between the API and
+ the shipped layer files.
+* The curated layer list, symbology and scale thresholds come from
+ ``core/gis-curated-layers.yml``.
+
+EDR IS DELIBERATELY ABSENT
+--------------------------
+``waterlevels`` and ``water_chemistry`` are served by the EDR provider only
+(see ``EDR_COLLECTIONS`` in ``core/pygeoapi.py``) -- they publish no ``/items``
+endpoint. Neither QGIS nor ArcGIS Pro ships an OGC API - EDR client, so a layer
+file pointing at either would fail to open. The curated layers use the feature
+collections that carry the same measurements summarised per site.
+
+CREDENTIALS ARE NEVER EMBEDDED
+------------------------------
+The internal mount is gated by per-user API keys (see
+``docs/internal-ogc-desktop-gis.md``). QGIS's connection format has ``username``
+and ``password`` attributes and Esri's ``CIMInternetServerConnection`` has a
+``user`` field, so embedding a credential is *possible* in both -- and is not
+done here. A shared file carrying one person's key would defeat per-user
+issuance and revocation. Internal artifacts ship credential-free and the user
+attaches their own key once, in their own client.
+
+Read ``docs/ogc-desktop-gis-artifacts.md`` before changing any of the emitted
+formats.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field as dataclass_field
+from pathlib import Path
+from xml.sax.saxutils import escape, quoteattr
+
+import yaml
+
+from core.ogc_field_metadata import default_title, table_entries
+
+# QGIS stores OGC API - Features connections in the same settings tree as
+# classic WFS ones, discriminated by this version string. Confirmed against
+# QGIS 4.0.1: the key is `qgis/connections-wfs//version`.
+QGIS_OAPIF_VERSION = "OGC_API_FEATURES"
+
+# QGIS provider key for OGC API - Features. Distinct from "WFS" -- the OAPIF
+# provider is a separate plugin (libprovider_wfs.so registers both).
+QGIS_PROVIDER = "OAPIF"
+
+# Page size requested by the generated artifacts. Both clients page through
+# `items`; a larger page means fewer round trips on collections in the
+# thousands, which is all of the well layers.
+DEFAULT_PAGE_SIZE = 1000
+
+_CURATED_CACHE: dict | None = None
+
+
+def _curated_path() -> Path:
+ return Path(__file__).resolve().parent.parent / "core" / "gis-curated-layers.yml"
+
+
+@dataclass(frozen=True)
+class Connection:
+ """One service endpoint to register in a client."""
+
+ name: str
+ url: str
+
+
+@dataclass
+class CuratedLayer:
+ """One curated layer, as declared in core/gis-curated-layers.yml."""
+
+ id: str
+ collection: str
+ title: str
+ abstract: str = ""
+ geometry: str = "Point"
+ min_scale: float | None = None
+ renderer: dict = dataclass_field(default_factory=dict)
+
+
+def load_curated_layers(refresh: bool = False) -> list[CuratedLayer]:
+ """Parse the curated-layer config, once per process."""
+ global _CURATED_CACHE
+ if _CURATED_CACHE is None or refresh:
+ raw = yaml.safe_load(_curated_path().read_text(encoding="utf-8")) or {}
+ entries = raw.get("layers") or []
+ if not isinstance(entries, list):
+ raise ValueError("gis-curated-layers.yml: `layers` must be a list.")
+ layers = []
+ for entry in entries:
+ missing = {"id", "collection", "title"} - set(entry)
+ if missing:
+ raise ValueError(
+ f"gis-curated-layers.yml: layer entry missing {sorted(missing)}."
+ )
+ renderer = entry.get("renderer") or {}
+ if renderer.get("type") not in {"single", "graduated", "categorized"}:
+ raise ValueError(
+ f"gis-curated-layers.yml: {entry['id']} has unsupported renderer "
+ f"type {renderer.get('type')!r}."
+ )
+ layers.append(
+ CuratedLayer(
+ id=entry["id"],
+ collection=entry["collection"],
+ title=entry["title"],
+ abstract=entry.get("abstract", "") or "",
+ geometry=entry.get("geometry", "Point"),
+ min_scale=entry.get("min_scale"),
+ renderer=renderer,
+ )
+ )
+ ids = [layer.id for layer in layers]
+ duplicates = {i for i in ids if ids.count(i) > 1}
+ if duplicates:
+ raise ValueError(
+ f"gis-curated-layers.yml: duplicate layer ids {sorted(duplicates)}."
+ )
+ _CURATED_CACHE = layers
+ return list(_CURATED_CACHE)
+
+
+def find_curated_layer(layer_id: str) -> CuratedLayer | None:
+ for layer in load_curated_layers():
+ if layer.id == layer_id:
+ return layer
+ return None
+
+
+# --------------------------------------------------------------------------
+# Field documentation shared by both clients
+# --------------------------------------------------------------------------
+
+
+def collection_fields(session, collection: str) -> set[str] | None:
+ """Column names of the view backing ``collection``, or None if absent.
+
+ ``core/ogc-field-descriptions.yml``'s ``_defaults`` block is a shared pool,
+ not a set of universal columns -- it carries well fields and geothermal
+ fields side by side, and ``describe_fields`` only ever applies the ones a
+ given view actually reflects. Without the same intersection here, a nine
+ column collection ships aliases for forty-two fields: harmless in QGIS,
+ which silently drops the ones it cannot match, but ArcGIS Pro takes
+ ``fieldDescriptions`` at its word.
+
+ Returns None when the view is not present, which happens on a branch whose
+ migrations have not created it yet. The caller then falls back to the full
+ entry list rather than emitting a layer file with no aliases at all.
+ """
+ from sqlalchemy import text
+
+ row = (
+ session.execute(
+ text(
+ "SELECT column_name FROM information_schema.columns "
+ "WHERE table_schema = 'public' AND table_name = :table"
+ ),
+ {"table": f"ogc_{collection}"},
+ )
+ .scalars()
+ .all()
+ )
+ return set(row) or None
+
+
+def field_aliases(collection: str, fields: set[str] | None = None) -> dict[str, str]:
+ """Human-readable label per field, from the OGC field-description YAML.
+
+ ``fields`` restricts the result to columns the view really has; see
+ ``collection_fields``. Omitting it emits every documented entry.
+ """
+ return {
+ name: entry.get("title") or default_title(name)
+ for name, entry in table_entries(collection).items()
+ if fields is None or name in fields
+ }
+
+
+def field_value_maps(layer: "CuratedLayer") -> dict[str, dict[str, str]]:
+ """Display-label -> stored-value mapping per field, for a client value map.
+
+ Only fields whose label genuinely differs from the stored value get an
+ entry. The lexicon-backed columns (``thing_type``, ``release_status`` and
+ friends) store terms that already read as prose, so mapping them to
+ themselves would add a few kilobytes of ``name == value`` noise per layer
+ and give the user a dropdown that renames nothing. The curated renderer
+ categories are the real case: ``increasing`` means the water table is
+ falling, which no reader should have to know.
+ """
+ renderer = layer.renderer
+ if renderer.get("type") != "categorized":
+ return {}
+ mapping = {
+ str(item["label"]): str(item["value"])
+ for item in renderer.get("categories", [])
+ if str(item["label"]) != str(item["value"])
+ }
+ return {renderer["field"]: mapping} if mapping else {}
+
+
+# --------------------------------------------------------------------------
+# QGIS
+# --------------------------------------------------------------------------
+
+
+def qgis_connections_xml(connections: list[Connection]) -> str:
+ """A QGIS Data Source Manager connections file.
+
+ Format taken from ``QgsManageConnectionsDialog::saveWfsConnections`` --
+ root ``qgsWFSConnections`` version 1.0, one ``wfs`` child per connection.
+ Only the attributes we actually want to pin are written; QGIS fills the
+ rest from its own defaults on import.
+
+ Imported through **Browser panel > right-click "WFS / OGC API - Features"
+ > Load Connections**.
+ """
+ lines = [
+ "",
+ '',
+ ]
+ for connection in connections:
+ lines.append(
+ " "
+ )
+ lines.append(" ")
+ return "\n".join(lines) + "\n"
+
+
+def qgis_datasource_uri(base_url: str, collection: str) -> str:
+ """The OAPIF provider URI for one collection."""
+ return f"url='{base_url}' typename='{collection}' pageSize='{DEFAULT_PAGE_SIZE}'"
+
+
+def _qgis_marker_symbol(name: str, color: str, size: float, shape: str, outline: str):
+ return (
+ f' \n'
+ ' \n'
+ ' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ ' \n'
+ " \n"
+ " \n"
+ " "
+ )
+
+
+def _qgis_renderer(renderer: dict) -> str:
+ kind = renderer["type"]
+ shape = renderer.get("shape", "circle")
+ outline = renderer.get("outline_color", "35,35,35,255")
+ base_size = renderer.get("size", 2.4)
+
+ if kind == "single":
+ symbol = _qgis_marker_symbol(
+ "0", renderer.get("color", "31,119,180,255"), base_size, shape, outline
+ )
+ return (
+ ' \n'
+ " \n" + symbol + "\n \n"
+ " "
+ )
+
+ if kind == "categorized":
+ categories, symbols = [], []
+ for index, item in enumerate(renderer["categories"]):
+ categories.append(
+ f' '
+ )
+ symbols.append(
+ _qgis_marker_symbol(
+ str(index),
+ item["color"],
+ item.get("size", base_size),
+ shape,
+ outline,
+ )
+ )
+ return (
+ f' \n'
+ " \n" + "\n".join(categories) + "\n \n"
+ " \n" + "\n".join(symbols) + "\n \n"
+ " "
+ )
+
+ ranges, symbols = [], []
+ for index, item in enumerate(renderer["classes"]):
+ ranges.append(
+ f' '
+ )
+ symbols.append(
+ _qgis_marker_symbol(
+ str(index), item["color"], item.get("size", base_size), shape, outline
+ )
+ )
+ return (
+ f' \n'
+ " \n" + "\n".join(ranges) + "\n \n"
+ " \n" + "\n".join(symbols) + "\n \n"
+ " "
+ )
+
+
+def qgis_layer_definition(
+ layer: CuratedLayer, base_url: str, fields: set[str] | None = None
+) -> str:
+ """A QGIS layer definition (.qlr) for one curated layer.
+
+ Deliberately minimal: QGIS fills every element this omits with its own
+ defaults on load. Verified against QGIS 4.0.1 -- the emitted file loads to
+ a valid layer with the renderer, aliases and scale visibility applied.
+ """
+ uri = qgis_datasource_uri(base_url, layer.collection)
+ aliases = field_aliases(layer.collection, fields)
+ value_maps = field_value_maps(layer)
+
+ alias_lines = [
+ f' "
+ for index, (name, title) in enumerate(sorted(aliases.items()))
+ ]
+
+ # A value map relabels the stored token in the attribute table and the
+ # feature form. Shape copied from what QGIS itself writes: the "map" List
+ # holds one nested per entry, whose single child is
+ # named for the DISPLAY label and carries the STORED value. Flattening
+ # that wrapper away segfaults QGIS 4.0.1 on load rather than erroring.
+ widget_lines = []
+ for name, mapping in sorted(value_maps.items()):
+ options = "".join(
+ ' \n'
+ f" \n'
+ " \n"
+ for label, value in mapping.items()
+ )
+ widget_lines.append(
+ f' \n'
+ ' \n'
+ " \n"
+ ' \n'
+ ' \n'
+ f"{options}"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ " "
+ )
+
+ scale_attrs = ""
+ if layer.min_scale:
+ scale_attrs = (
+ f' hasScaleBasedVisibilityFlag="1" minScale="{layer.min_scale}"'
+ ' maxScale="0"'
+ )
+
+ field_config = ""
+ if widget_lines:
+ field_config = (
+ " \n"
+ + "\n".join(widget_lines)
+ + "\n \n"
+ )
+
+ return (
+ "\n"
+ "\n"
+ ' \n'
+ f" \n"
+ " \n"
+ " \n"
+ " \n"
+ " \n"
+ f' \n'
+ f" {escape(layer.id)} \n"
+ f" {escape(uri)} \n"
+ f" {escape(layer.title)} \n"
+ f" {escape(layer.abstract)} \n"
+ " OGC:CRS84 \n"
+ f" {QGIS_PROVIDER} \n"
+ f"{_qgis_renderer(layer.renderer)}\n"
+ " \n" + "\n".join(alias_lines) + "\n \n"
+ f"{field_config}"
+ " \n"
+ " \n"
+ " \n"
+ )
+
+
+# --------------------------------------------------------------------------
+# ArcGIS Pro
+# --------------------------------------------------------------------------
+
+# CIM types per Esri's published spec (Esri/cim-spec, docs/v3):
+# CIMOGCAPIServiceConnection carries serviceName + serverConnection, and
+# CIMInternetServerConnection carries the URL. The spec marks the connection's
+# `password` "not persisted in documents", which is the same reason the
+# internal artifacts here carry no credential.
+CIM_VERSION = "3.3.0"
+
+
+def _cim_color(rgba: str) -> dict:
+ r, g, b, a = (int(part) for part in rgba.split(","))
+ return {"type": "CIMRGBColor", "values": [r, g, b, round(a / 255 * 100, 2)]}
+
+
+def _cim_marker(rgba: str, size: float) -> dict:
+ return {
+ "type": "CIMPointSymbol",
+ "symbolLayers": [
+ {
+ "type": "CIMVectorMarker",
+ "enable": True,
+ "size": size * 2,
+ "frame": {"xmin": -2, "ymin": -2, "xmax": 2, "ymax": 2},
+ "markerGraphics": [
+ {
+ "type": "CIMMarkerGraphic",
+ "geometry": {"x": 0, "y": 0},
+ "symbol": {
+ "type": "CIMPolygonSymbol",
+ "symbolLayers": [
+ {
+ "type": "CIMSolidFill",
+ "enable": True,
+ "color": _cim_color(rgba),
+ }
+ ],
+ },
+ }
+ ],
+ }
+ ],
+ }
+
+
+def _cim_renderer(renderer: dict) -> dict:
+ kind = renderer["type"]
+ size = renderer.get("size", 2.4)
+
+ if kind == "single":
+ return {
+ "type": "CIMSimpleRenderer",
+ "patch": "Default",
+ "symbol": {
+ "type": "CIMSymbolReference",
+ "symbol": _cim_marker(renderer.get("color", "31,119,180,255"), size),
+ },
+ }
+
+ if kind == "categorized":
+ groups = [
+ {
+ "type": "CIMUniqueValueGroup",
+ "classes": [
+ {
+ "type": "CIMUniqueValueClass",
+ "label": item["label"],
+ "patch": "Default",
+ "symbol": {
+ "type": "CIMSymbolReference",
+ "symbol": _cim_marker(
+ item["color"], item.get("size", size)
+ ),
+ },
+ "values": [
+ {
+ "type": "CIMUniqueValue",
+ "fieldValues": [str(item["value"])],
+ }
+ ],
+ "visible": True,
+ }
+ for item in renderer["categories"]
+ ],
+ }
+ ]
+ return {
+ "type": "CIMUniqueValueRenderer",
+ "fields": [renderer["field"]],
+ "groups": groups,
+ "useDefaultSymbol": True,
+ }
+
+ return {
+ "type": "CIMClassBreaksRenderer",
+ "classBreakType": "GraduatedColor",
+ "classificationMethod": "Manual",
+ "field": renderer["field"],
+ "breaks": [
+ {
+ "type": "CIMClassBreak",
+ "label": item["label"],
+ "patch": "Default",
+ "upperBound": item["upper"],
+ "symbol": {
+ "type": "CIMSymbolReference",
+ "symbol": _cim_marker(item["color"], item.get("size", size)),
+ },
+ }
+ for item in renderer["classes"]
+ ],
+ }
+
+
+def arcgis_layer_file(
+ layer: CuratedLayer, base_url: str, fields: set[str] | None = None
+) -> str:
+ """An ArcGIS Pro layer file (.lyrx) for one curated layer.
+
+ NOTE: unlike the QGIS artifacts, this has NOT been verified by opening it
+ in the target client -- no ArcGIS Pro is available to this project. It is
+ built to Esri's published CIM spec. Treat the first open in Pro as the real
+ test. See docs/ogc-desktop-gis-artifacts.md.
+ """
+ aliases = field_aliases(layer.collection, fields)
+ connection = {
+ "type": "CIMOGCAPIServiceConnection",
+ "serviceName": layer.collection,
+ "serverConnection": {
+ "type": "CIMInternetServerConnection",
+ "anonymous": True,
+ "hideUserProperty": True,
+ "URL": base_url,
+ },
+ }
+
+ definition = {
+ "type": "CIMLayerDocument",
+ "version": CIM_VERSION,
+ "layers": [f"CIMPATH=/{layer.id}.xml"],
+ "layerDefinitions": [
+ {
+ "type": "CIMFeatureLayer",
+ "name": layer.title,
+ "uRI": f"CIMPATH=/{layer.id}.xml",
+ "description": layer.abstract,
+ "visibility": True,
+ "expanded": True,
+ "layerType": "Operational",
+ "minScale": layer.min_scale or 0,
+ "maxScale": 0,
+ "featureTable": {
+ "type": "CIMFeatureTable",
+ "displayField": "name",
+ "editable": False,
+ "dataConnection": connection,
+ "studyAreaSpatialRel": "esriSpatialRelUndefined",
+ "searchOrder": "esriSearchOrderSpatial",
+ "fieldDescriptions": [
+ {
+ "type": "CIMFieldDescription",
+ "alias": title,
+ "fieldName": name,
+ "visible": True,
+ "searchMode": "Exact",
+ }
+ for name, title in sorted(aliases.items())
+ ],
+ },
+ "renderer": _cim_renderer(layer.renderer),
+ "scaleSymbols": True,
+ "snappable": False,
+ }
+ ],
+ }
+ return json.dumps(definition, indent=2) + "\n"
+
+
+# ============= EOF =============================================
diff --git a/tests/test_authorization.py b/tests/test_authorization.py
index 02ce5caee..5593d8e9e 100644
--- a/tests/test_authorization.py
+++ b/tests/test_authorization.py
@@ -54,6 +54,12 @@
("GET", "/docs-auth/oauth2-redirect"),
("GET", "/openapi-auth.json"),
("GET", "/disclaimer"),
+ # Desktop-GIS artifacts describe the anonymous /ogcapi mount and embed
+ # no credential; a QGIS client fetching one has nothing to present.
+ ("GET", "/gis"),
+ ("GET", "/gis/qgis/connections.xml"),
+ ("GET", "/gis/qgis/layers/{layer_id}.qlr"),
+ ("GET", "/gis/arcgis/layers/{layer_id}.lyrx"),
("GET", "/ngwmn/waterlevels/{pointid}"),
("GET", "/ngwmn/wellconstruction/{pointid}"),
("GET", "/ngwmn/lithology/{pointid}"),
diff --git a/tests/test_gis_artifacts.py b/tests/test_gis_artifacts.py
new file mode 100644
index 000000000..e9f855c18
--- /dev/null
+++ b/tests/test_gis_artifacts.py
@@ -0,0 +1,321 @@
+# ===============================================================================
+# Copyright 2026 ross
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ===============================================================================
+"""Guards on the generated QGIS and ArcGIS Pro artifacts.
+
+These assert the invariants a broken artifact would violate silently -- the
+file still parses, still downloads, and only fails when a GIS user opens it
+hours later. Loading a .qlr into a real QGIS is the check that actually proves
+the format, and QGIS is not a CI dependency; see
+docs/ogc-desktop-gis-artifacts.md for the manual procedure and what it covered.
+"""
+
+import json
+import xml.etree.ElementTree as ET
+
+import pytest
+
+from core.ogc_field_metadata import table_entries
+from tests import client
+from core.pygeoapi import EDR_COLLECTIONS, THING_COLLECTIONS
+from services.gis_artifacts import (
+ Connection,
+ arcgis_layer_file,
+ field_value_maps,
+ find_curated_layer,
+ load_curated_layers,
+ qgis_connections_xml,
+ qgis_datasource_uri,
+ qgis_layer_definition,
+)
+
+BASE = "https://example.org/ogcapi"
+
+
+@pytest.fixture(scope="module")
+def layers():
+ return load_curated_layers()
+
+
+def test_curated_config_parses(layers):
+ assert layers, "gis-curated-layers.yml declares no layers."
+
+
+@pytest.mark.parametrize("layer_id", [layer.id for layer in load_curated_layers()])
+def test_qlr_is_well_formed_and_points_at_the_collection(layer_id):
+ layer = find_curated_layer(layer_id)
+ root = ET.fromstring(qgis_layer_definition(layer, BASE))
+
+ assert root.tag == "qlr"
+ maplayer = root.find("./maplayers/maplayer")
+ assert maplayer.findtext("provider") == "OAPIF"
+ assert maplayer.findtext("datasource") == qgis_datasource_uri(
+ BASE, layer.collection
+ )
+ # The layer-tree entry and the maplayer must agree, or QGIS loads the tree
+ # node and finds no layer behind it.
+ tree_layer = root.find("./layer-tree-group/layer-tree-layer")
+ assert tree_layer.get("source") == maplayer.findtext("datasource")
+ assert tree_layer.get("id") == maplayer.findtext("id")
+
+
+@pytest.mark.parametrize("layer_id", [layer.id for layer in load_curated_layers()])
+def test_qlr_renderer_field_exists_on_the_collection(layer_id):
+ """A renderer pointed at a field the view does not have renders nothing."""
+ layer = find_curated_layer(layer_id)
+ field = layer.renderer.get("field")
+ if field is None:
+ pytest.skip("single-symbol renderer classifies on no field")
+ assert field in table_entries(layer.collection), (
+ f"{layer.id} classifies on {field!r}, which has no entry for "
+ f"{layer.collection} in core/ogc-field-descriptions.yml."
+ )
+
+
+@pytest.mark.parametrize("layer_id", [layer.id for layer in load_curated_layers()])
+def test_qlr_aliases_cover_every_documented_field(layer_id):
+ layer = find_curated_layer(layer_id)
+ root = ET.fromstring(qgis_layer_definition(layer, BASE))
+ aliased = {a.get("field") for a in root.findall(".//aliases/alias")}
+ assert aliased == set(table_entries(layer.collection))
+
+
+def test_qlr_value_map_entries_are_nested_option_maps():
+ """QGIS 4.0.1 segfaults on a flattened value map rather than erroring.
+
+ Each entry must be its own wrapper whose single child
+ is named for the display label and carries the stored value.
+ """
+ layer = next(
+ layer
+ for layer in load_curated_layers()
+ if layer.renderer.get("type") == "categorized"
+ )
+ root = ET.fromstring(qgis_layer_definition(layer, BASE))
+ entries = root.findall(
+ ".//fieldConfiguration/field/editWidget/config/Option/Option/Option"
+ )
+ assert entries, "categorized layer emitted no value map"
+ for entry in entries:
+ assert entry.get("type") == "Map"
+ children = list(entry)
+ assert len(children) == 1
+ assert children[0].get("type") == "QString"
+
+
+def test_value_maps_skip_identity_mappings():
+ """A label equal to its stored value is noise, not documentation."""
+ for layer in load_curated_layers():
+ for mapping in field_value_maps(layer).values():
+ assert all(label != value for label, value in mapping.items())
+
+
+def _served_feature_collections() -> set[str]:
+ """Every OGC API - Features collection this branch actually publishes.
+
+ Two sources, because the collection list is split: the thing collections
+ are built in core/pygeoapi.py, and the derived/summary ones are declared
+ in the core/pygeoapi-config.yml template.
+ """
+ import re
+ from pathlib import Path
+
+ served = {c["id"] for c in THING_COLLECTIONS}
+ template = Path("core/pygeoapi-config.yml").read_text(encoding="utf-8")
+ body = template.split("resources:", 1)[-1]
+ served |= set(re.findall(r"^ ([a-z0-9_]+):", body, re.MULTILINE))
+ return served
+
+
+def test_every_curated_layer_names_a_collection_this_branch_serves():
+ """A curated layer pointing at a collection we do not publish 404s in QGIS.
+
+ Production advertises more collections than this branch defines, so a
+ layer list written against a live deployment can name one that does not
+ exist here.
+ """
+ served = _served_feature_collections()
+ missing = {
+ layer.id: layer.collection
+ for layer in load_curated_layers()
+ if layer.collection not in served
+ }
+ assert not missing, (
+ f"{missing} name collections this branch does not serve. "
+ f"Served: {sorted(served)}"
+ )
+
+
+def test_no_curated_layer_points_at_an_edr_collection():
+ """Neither desktop client has an EDR reader; such a layer cannot open."""
+ edr = {collection["id"] for collection in EDR_COLLECTIONS}
+ offenders = [layer.id for layer in load_curated_layers() if layer.collection in edr]
+ assert not offenders, (
+ f"{offenders} point at EDR-only collections {sorted(edr)}, which "
+ "publish no /items endpoint."
+ )
+
+
+def test_connections_xml_shape():
+ xml = qgis_connections_xml(
+ [Connection("A", "https://a.example/ogcapi"), Connection("B", "https://b/x")]
+ )
+ root = ET.fromstring(xml.split("\n", 1)[1])
+ assert root.tag == "qgsWFSConnections"
+ assert root.get("version") == "1.0"
+ entries = root.findall("wfs")
+ assert [e.get("name") for e in entries] == ["A", "B"]
+ for entry in entries:
+ # Without this QGIS registers a classic WFS connection instead, which
+ # then fails against a service that speaks only OGC API - Features.
+ assert entry.get("version") == "OGC_API_FEATURES"
+
+
+def test_connections_xml_escapes_the_connection_name():
+ xml = qgis_connections_xml([Connection('Ampersand & "quote"', BASE)])
+ root = ET.fromstring(xml.split("\n", 1)[1])
+ assert root.find("wfs").get("name") == 'Ampersand & "quote"'
+
+
+def test_no_artifact_embeds_a_credential():
+ """Per-user keys must never be baked into a shared file."""
+ for layer in load_curated_layers():
+ qlr = qgis_layer_definition(layer, BASE)
+ lyrx = arcgis_layer_file(layer, BASE)
+ for body in (qlr, lyrx):
+ lowered = body.lower()
+ assert "password" not in lowered
+ assert "token" not in lowered
+ connections = qgis_connections_xml([Connection("A", BASE)])
+ assert "password" not in connections.lower()
+ assert "username" not in connections.lower()
+
+
+@pytest.mark.parametrize("layer_id", [layer.id for layer in load_curated_layers()])
+def test_lyrx_is_valid_cim_json(layer_id):
+ layer = find_curated_layer(layer_id)
+ doc = json.loads(arcgis_layer_file(layer, BASE))
+
+ assert doc["type"] == "CIMLayerDocument"
+ definition = doc["layerDefinitions"][0]
+ assert definition["type"] == "CIMFeatureLayer"
+ # The layer's uRI must be listed in `layers`, or Pro shows an empty file.
+ assert definition["uRI"] in doc["layers"]
+
+ connection = definition["featureTable"]["dataConnection"]
+ assert connection["type"] == "CIMOGCAPIServiceConnection"
+ assert connection["serviceName"] == layer.collection
+ assert connection["serverConnection"]["URL"] == BASE
+
+
+@pytest.mark.parametrize("layer_id", [layer.id for layer in load_curated_layers()])
+def test_lyrx_renderer_matches_the_qlr_renderer(layer_id):
+ """The two clients must not disagree about how a layer is symbolised."""
+ layer = find_curated_layer(layer_id)
+ renderer = json.loads(arcgis_layer_file(layer, BASE))["layerDefinitions"][0][
+ "renderer"
+ ]
+ expected = {
+ "single": "CIMSimpleRenderer",
+ "categorized": "CIMUniqueValueRenderer",
+ "graduated": "CIMClassBreaksRenderer",
+ }[layer.renderer["type"]]
+ assert renderer["type"] == expected
+
+ if layer.renderer["type"] == "categorized":
+ values = [
+ value["fieldValues"][0]
+ for group in renderer["groups"]
+ for klass in group["classes"]
+ for value in klass["values"]
+ ]
+ assert values == [str(item["value"]) for item in layer.renderer["categories"]]
+ elif layer.renderer["type"] == "graduated":
+ bounds = [brk["upperBound"] for brk in renderer["breaks"]]
+ assert bounds == [item["upper"] for item in layer.renderer["classes"]]
+
+
+# ============= EOF =============================================
+
+
+# --------------------------------------------------------------------------
+# Routes
+# --------------------------------------------------------------------------
+
+
+def test_index_lists_every_curated_layer():
+ response = client.get("/gis")
+ assert response.status_code == 200
+ for layer in load_curated_layers():
+ assert f"qgis/layers/{layer.id}.qlr" in response.text
+ assert f"arcgis/layers/{layer.id}.lyrx" in response.text
+
+
+def test_connections_download_is_an_attachment():
+ response = client.get("/gis/qgis/connections.xml")
+ assert response.status_code == 200
+ assert "attachment" in response.headers["content-disposition"]
+ assert "OGC_API_FEATURES" in response.text
+
+
+def test_layer_downloads_carry_their_extension():
+ layer = load_curated_layers()[0]
+ qlr = client.get(f"/gis/qgis/layers/{layer.id}.qlr")
+ assert qlr.status_code == 200
+ assert f'filename="{layer.id}.qlr"' in qlr.headers["content-disposition"]
+
+ lyrx = client.get(f"/gis/arcgis/layers/{layer.id}.lyrx")
+ assert lyrx.status_code == 200
+ assert json.loads(lyrx.text)["type"] == "CIMLayerDocument"
+
+
+def test_unknown_layer_is_a_404():
+ assert client.get("/gis/qgis/layers/not-a-layer.qlr").status_code == 404
+ assert client.get("/gis/arcgis/layers/not-a-layer.lyrx").status_code == 404
+
+
+def test_served_artifacts_use_the_advertised_pygeoapi_url():
+ """The artifact must not send a client to a host pygeoapi will contradict."""
+ from core.pygeoapi import _server_url
+
+ response = client.get("/gis/qgis/connections.xml")
+ assert _server_url() in response.text
+
+
+def test_field_filter_trims_aliases_to_the_views_real_columns():
+ """_defaults is a shared pool, not a set of universal columns.
+
+ Unfiltered, a nine-column collection ships aliases for every documented
+ field in the file -- including geothermal ones. ArcGIS Pro takes
+ fieldDescriptions at its word, so the filter has to bite.
+ """
+ layer = find_curated_layer("water-level-trend")
+ real = {"id", "name", "trend_category", "slope_ft_per_year"}
+
+ unfiltered = ET.fromstring(qgis_layer_definition(layer, BASE))
+ filtered = ET.fromstring(qgis_layer_definition(layer, BASE, real))
+
+ all_aliases = {a.get("field") for a in unfiltered.findall(".//aliases/alias")}
+ kept = {a.get("field") for a in filtered.findall(".//aliases/alias")}
+
+ assert kept == real
+ assert len(all_aliases) > len(kept)
+ # A field from another table's block must not survive the filter.
+ assert "api" in all_aliases and "api" not in kept
+
+ described = json.loads(arcgis_layer_file(layer, BASE, real))["layerDefinitions"][0][
+ "featureTable"
+ ]["fieldDescriptions"]
+ assert {f["fieldName"] for f in described} == real
From 70b21b893e7e9f3f071e2b37a757018ffd6c8bae Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sun, 23 Aug 2026 10:26:30 -0700
Subject: [PATCH 144/151] fix(gis): document the content type the artifact
routes actually send
The four download routes carried response_class=PlainTextResponse, chosen for
convenience rather than accuracy. Each returns a raw Response setting its own
media_type, so OpenAPI advertised text/plain while the endpoint sent text/xml
or application/json. Harmless to a client that ignores the schema, wrong for
anything generating from it.
Rather than restate the media type in the decorator, it now comes from the
Response subclass in both places: XmlAttachment and JsonAttachment each declare
`media_type`, FastAPI reads it off `response_class` to document the operation,
and `_attachment` instantiates that same class. One definition, so the two
cannot drift apart again.
JsonAttachment deliberately subclasses Response rather than JSONResponse. The
.lyrx body is already serialised, and re-encoding it would escape the whole CIM
document into a JSON string.
test_openapi_advertises_the_content_type_actually_returned compares the
documented content type against the served one for all three download shapes;
it fails on the previous code.
uv run pytest --ignore=tests/transfers -> 1143 passed, 84 skipped, 6 xpassed
Co-Authored-By: Claude Opus 5
---
api/gis_artifacts.py | 39 +++++++++++++++++++++++++------------
tests/test_gis_artifacts.py | 34 ++++++++++++++++++++++++++++++++
2 files changed, 61 insertions(+), 12 deletions(-)
diff --git a/api/gis_artifacts.py b/api/gis_artifacts.py
index 915fd2d87..9f65181b6 100644
--- a/api/gis_artifacts.py
+++ b/api/gis_artifacts.py
@@ -30,7 +30,7 @@
"""
from fastapi import APIRouter, HTTPException
-from fastapi.responses import HTMLResponse, PlainTextResponse, Response
+from fastapi.responses import HTMLResponse, Response
from core.app import in_public_schema
from core.dependencies import session_dependency, viewer_dependency
@@ -51,10 +51,25 @@
INTERNAL_CONNECTION_NAME = "NMBGMR Ocotillo (internal)"
-def _attachment(body: str, media_type: str, filename: str) -> Response:
- return Response(
+class XmlAttachment(Response):
+ """An XML download. The media type lives here so the OpenAPI schema and
+ the response itself cannot disagree: FastAPI reads `media_type` off the
+ `response_class` to document the operation, and `_attachment` returns an
+ instance of that same class rather than restating the string."""
+
+ media_type = "text/xml"
+
+
+class JsonAttachment(Response):
+ """A JSON download. Not JSONResponse: the body is already serialised, and
+ re-encoding it would escape the CIM document into a JSON string."""
+
+ media_type = "application/json"
+
+
+def _attachment(response_class: type[Response], body: str, filename: str) -> Response:
+ return response_class(
content=body,
- media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@@ -68,7 +83,7 @@ def _public_base() -> str:
return _server_url()
-@router.get("/qgis/connections.xml", response_class=PlainTextResponse)
+@router.get("/qgis/connections.xml", response_class=XmlAttachment)
@in_public_schema
def qgis_connections() -> Response:
"""QGIS connections file registering the public OGC API - Features mount.
@@ -77,10 +92,10 @@ def qgis_connections() -> Response:
Load Connections**.
"""
body = qgis_connections_xml([Connection(PUBLIC_CONNECTION_NAME, _public_base())])
- return _attachment(body, "text/xml", "ocotillo-ogcapi-connections.xml")
+ return _attachment(XmlAttachment, body, "ocotillo-ogcapi-connections.xml")
-@router.get("/qgis/connections-internal.xml", response_class=PlainTextResponse)
+@router.get("/qgis/connections-internal.xml", response_class=XmlAttachment)
def qgis_connections_internal(user: viewer_dependency) -> Response:
"""QGIS connections file covering the public and internal mounts.
@@ -94,10 +109,10 @@ def qgis_connections_internal(user: viewer_dependency) -> Response:
Connection(INTERNAL_CONNECTION_NAME, _internal_server_url()),
]
)
- return _attachment(body, "text/xml", "ocotillo-ogcapi-connections-internal.xml")
+ return _attachment(XmlAttachment, body, "ocotillo-ogcapi-connections-internal.xml")
-@router.get("/qgis/layers/{layer_id}.qlr", response_class=PlainTextResponse)
+@router.get("/qgis/layers/{layer_id}.qlr", response_class=XmlAttachment)
@in_public_schema
def qgis_layer(layer_id: str, session: session_dependency) -> Response:
"""A styled QGIS layer definition for one curated layer."""
@@ -106,10 +121,10 @@ def qgis_layer(layer_id: str, session: session_dependency) -> Response:
raise HTTPException(status_code=404, detail=f"No curated layer {layer_id!r}.")
fields = collection_fields(session, layer.collection)
body = qgis_layer_definition(layer, _public_base(), fields)
- return _attachment(body, "text/xml", f"{layer_id}.qlr")
+ return _attachment(XmlAttachment, body, f"{layer_id}.qlr")
-@router.get("/arcgis/layers/{layer_id}.lyrx", response_class=PlainTextResponse)
+@router.get("/arcgis/layers/{layer_id}.lyrx", response_class=JsonAttachment)
@in_public_schema
def arcgis_layer(layer_id: str, session: session_dependency) -> Response:
"""A styled ArcGIS Pro layer file for one curated layer."""
@@ -118,7 +133,7 @@ def arcgis_layer(layer_id: str, session: session_dependency) -> Response:
raise HTTPException(status_code=404, detail=f"No curated layer {layer_id!r}.")
fields = collection_fields(session, layer.collection)
body = arcgis_layer_file(layer, _public_base(), fields)
- return _attachment(body, "application/json", f"{layer_id}.lyrx")
+ return _attachment(JsonAttachment, body, f"{layer_id}.lyrx")
_PAGE_STYLE = (
diff --git a/tests/test_gis_artifacts.py b/tests/test_gis_artifacts.py
index e9f855c18..6a8f52fe2 100644
--- a/tests/test_gis_artifacts.py
+++ b/tests/test_gis_artifacts.py
@@ -319,3 +319,37 @@ def test_field_filter_trims_aliases_to_the_views_real_columns():
"featureTable"
]["fieldDescriptions"]
assert {f["fieldName"] for f in described} == real
+
+
+@pytest.mark.parametrize(
+ "template,path,expected",
+ [
+ ("/gis/qgis/connections.xml", "/gis/qgis/connections.xml", "text/xml"),
+ (
+ "/gis/qgis/layers/{layer_id}.qlr",
+ "/gis/qgis/layers/water-wells.qlr",
+ "text/xml",
+ ),
+ (
+ "/gis/arcgis/layers/{layer_id}.lyrx",
+ "/gis/arcgis/layers/water-wells.lyrx",
+ "application/json",
+ ),
+ ],
+)
+def test_openapi_advertises_the_content_type_actually_returned(
+ template, path, expected
+):
+ """The schema and the response must not disagree.
+
+ These routes return a raw Response with its own media type, so a
+ `response_class` chosen for convenience rather than accuracy documents a
+ content type the endpoint never sends. Both now come from the same
+ Response subclass; this pins them together.
+ """
+ schema = client.get("/openapi.json").json()
+ documented = set(schema["paths"][template]["get"]["responses"]["200"]["content"])
+ assert documented == {expected}
+
+ served = client.get(path).headers["content-type"]
+ assert served.split(";")[0] == expected
From 3aa6611ce96bf87722143fe304aa5d7f66bd1fd3 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Sun, 23 Aug 2026 10:35:30 -0700
Subject: [PATCH 145/151] feat(gis): serve the artifact catalogue as JSON for
frontend clients
/gis returned HTML only, so a frontend wanting to offer these downloads had to
hardcode the six curated layer ids and reconstruct their URLs. It now
negotiates: HTML by default for a human following the link, JSON on ?f=json or
an Accept: application/json header. Same precedence as api/disclaimer.py and
pygeoapi, so the surfaces behave alike.
The payload carries the service URL, the connection file, and per layer its id,
title, abstract, backing collection, that collection's URL, geometry, renderer
kind, and one download entry per client with href, media type and filename.
Enough to build a download UI without knowing anything about this router.
Hrefs are absolute and built from _app_base_url() rather than from the request:
a browser app on another origin has to use them unchanged, and a proxy
rewriting Host must not be able to redirect the caller. Tests fetch every
advertised href and assert the media type and filename match what the download
actually sends, so the catalogue cannot drift from the files.
Also declares 404 on the two layer routes. Both already raised it for an
unknown id, but only 422 was documented, so a generated client had no case for
the error it is most likely to hit.
uv run pytest --ignore=tests/transfers -> 1148 passed, 84 skipped, 6 xpassed
Co-Authored-By: Claude Opus 5
---
api/gis_artifacts.py | 88 ++++++++++++++++++++++++++++++++++---
tests/test_gis_artifacts.py | 57 ++++++++++++++++++++++++
2 files changed, 138 insertions(+), 7 deletions(-)
diff --git a/api/gis_artifacts.py b/api/gis_artifacts.py
index 9f65181b6..baa636319 100644
--- a/api/gis_artifacts.py
+++ b/api/gis_artifacts.py
@@ -29,12 +29,14 @@
Read docs/ogc-desktop-gis-artifacts.md before changing what is emitted.
"""
-from fastapi import APIRouter, HTTPException
-from fastapi.responses import HTMLResponse, Response
+from typing import Annotated
+
+from fastapi import APIRouter, HTTPException, Query, Request
+from fastapi.responses import HTMLResponse, JSONResponse, Response
from core.app import in_public_schema
from core.dependencies import session_dependency, viewer_dependency
-from core.pygeoapi import _internal_server_url, _server_url
+from core.pygeoapi import _app_base_url, _internal_server_url, _server_url
from services.gis_artifacts import (
Connection,
arcgis_layer_file,
@@ -74,6 +76,63 @@ def _attachment(response_class: type[Response], body: str, filename: str) -> Res
)
+def _wants_json(request: Request, f: str | None) -> bool:
+ # Same precedence as api/disclaimer.py and pygeoapi itself: an explicit
+ # ?f= beats the Accept header, so the surfaces behave alike.
+ if f is not None:
+ return f.lower() == "json"
+ accept = request.headers.get("accept", "")
+ return "application/json" in accept and "text/html" not in accept
+
+
+def _index_payload() -> dict:
+ """Machine-readable catalogue of every artifact this router serves.
+
+ Absolute hrefs, built from _app_base_url() rather than the request, so a
+ browser app on another origin can use them unchanged and a proxy that
+ rewrites Host cannot send the caller somewhere else.
+ """
+ root = _app_base_url()
+ service = _public_base()
+ return {
+ "service_url": service,
+ "connections": [
+ {
+ "client": "qgis",
+ "href": f"{root}/gis/qgis/connections.xml",
+ "media_type": XmlAttachment.media_type,
+ "filename": "ocotillo-ogcapi-connections.xml",
+ }
+ ],
+ "layers": [
+ {
+ "id": layer.id,
+ "title": layer.title,
+ "abstract": layer.abstract,
+ "collection": layer.collection,
+ "collection_url": f"{service}/collections/{layer.collection}",
+ "geometry": layer.geometry,
+ "renderer": layer.renderer.get("type"),
+ "downloads": [
+ {
+ "client": "qgis",
+ "href": f"{root}/gis/qgis/layers/{layer.id}.qlr",
+ "media_type": XmlAttachment.media_type,
+ "filename": f"{layer.id}.qlr",
+ },
+ {
+ "client": "arcgis",
+ "href": f"{root}/gis/arcgis/layers/{layer.id}.lyrx",
+ "media_type": JsonAttachment.media_type,
+ "filename": f"{layer.id}.lyrx",
+ },
+ ],
+ }
+ for layer in load_curated_layers()
+ ],
+ }
+
+
def _public_base() -> str:
# _server_url() is what pygeoapi stamps into its own `self`/`next` links.
# Deriving the artifact's URL from the same place means a client that
@@ -112,7 +171,11 @@ def qgis_connections_internal(user: viewer_dependency) -> Response:
return _attachment(XmlAttachment, body, "ocotillo-ogcapi-connections-internal.xml")
-@router.get("/qgis/layers/{layer_id}.qlr", response_class=XmlAttachment)
+@router.get(
+ "/qgis/layers/{layer_id}.qlr",
+ response_class=XmlAttachment,
+ responses={404: {"description": "No curated layer with that id."}},
+)
@in_public_schema
def qgis_layer(layer_id: str, session: session_dependency) -> Response:
"""A styled QGIS layer definition for one curated layer."""
@@ -124,7 +187,11 @@ def qgis_layer(layer_id: str, session: session_dependency) -> Response:
return _attachment(XmlAttachment, body, f"{layer_id}.qlr")
-@router.get("/arcgis/layers/{layer_id}.lyrx", response_class=JsonAttachment)
+@router.get(
+ "/arcgis/layers/{layer_id}.lyrx",
+ response_class=JsonAttachment,
+ responses={404: {"description": "No curated layer with that id."}},
+)
@in_public_schema
def arcgis_layer(layer_id: str, session: session_dependency) -> Response:
"""A styled ArcGIS Pro layer file for one curated layer."""
@@ -145,8 +212,15 @@ def arcgis_layer(layer_id: str, session: session_dependency) -> Response:
@router.get("", response_class=HTMLResponse)
@in_public_schema
-def gis_index() -> HTMLResponse:
- """Landing page listing every downloadable artifact."""
+def gis_index(request: Request, f: Annotated[str | None, Query()] = None) -> Response:
+ """Landing page listing every downloadable artifact.
+
+ HTML by default for a human following the link; `?f=json` (or an
+ Accept: application/json header) returns the same catalogue as data, so a
+ frontend can enumerate the layers instead of hardcoding their ids.
+ """
+ if _wants_json(request, f):
+ return JSONResponse(_index_payload())
base = _public_base()
rows = "".join(
f"{layer.title} "
diff --git a/tests/test_gis_artifacts.py b/tests/test_gis_artifacts.py
index 6a8f52fe2..e3bdf9b31 100644
--- a/tests/test_gis_artifacts.py
+++ b/tests/test_gis_artifacts.py
@@ -353,3 +353,60 @@ def test_openapi_advertises_the_content_type_actually_returned(
served = client.get(path).headers["content-type"]
assert served.split(";")[0] == expected
+
+
+def test_index_defaults_to_html_and_negotiates_json():
+ assert client.get("/gis").headers["content-type"].startswith("text/html")
+ assert (
+ client.get("/gis", params={"f": "json"})
+ .headers["content-type"]
+ .startswith("application/json")
+ )
+ assert (
+ client.get("/gis", headers={"Accept": "application/json"})
+ .headers["content-type"]
+ .startswith("application/json")
+ )
+
+
+def test_json_index_lets_a_frontend_enumerate_instead_of_hardcoding():
+ payload = client.get("/gis", params={"f": "json"}).json()
+ assert [entry["id"] for entry in payload["layers"]] == [
+ layer.id for layer in load_curated_layers()
+ ]
+ for entry in payload["layers"]:
+ assert {d["client"] for d in entry["downloads"]} == {"qgis", "arcgis"}
+
+
+def test_json_index_hrefs_are_absolute_and_resolve():
+ """A browser app on another origin has to be able to use them unchanged."""
+ payload = client.get("/gis", params={"f": "json"}).json()
+ hrefs = [payload["connections"][0]["href"]] + [
+ download["href"]
+ for entry in payload["layers"]
+ for download in entry["downloads"]
+ ]
+ for href in hrefs:
+ assert href.startswith("http://") or href.startswith("https://")
+ response = client.get(href)
+ assert response.status_code == 200, href
+
+
+def test_json_index_media_types_match_what_the_download_sends():
+ payload = client.get("/gis", params={"f": "json"}).json()
+ for entry in payload["layers"]:
+ for download in entry["downloads"]:
+ served = client.get(download["href"])
+ assert served.headers["content-type"].split(";")[0] == (
+ download["media_type"]
+ )
+ assert download["filename"] in served.headers["content-disposition"]
+
+
+def test_missing_layer_404_is_documented():
+ schema = client.get("/openapi.json").json()
+ for path in (
+ "/gis/qgis/layers/{layer_id}.qlr",
+ "/gis/arcgis/layers/{layer_id}.lyrx",
+ ):
+ assert "404" in schema["paths"][path]["get"]["responses"]
From 25824927c6f306e5e62f9390eb8e2ffd0d37be87 Mon Sep 17 00:00:00 2001
From: jirhiker <2035568+jirhiker@users.noreply.github.com>
Date: Sun, 23 Aug 2026 17:41:47 +0000
Subject: [PATCH 146/151] Formatting changes
---
api/gis_artifacts.py | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/api/gis_artifacts.py b/api/gis_artifacts.py
index baa636319..d815f0566 100644
--- a/api/gis_artifacts.py
+++ b/api/gis_artifacts.py
@@ -229,8 +229,7 @@ def gis_index(request: Request, f: Annotated[str | None, Query()] = None) -> Res
f'.lyrx '
for layer in load_curated_layers()
)
- return HTMLResponse(
- f"""
+ return HTMLResponse(f"""
Desktop GIS downloads
Using our OGC layers in QGIS and ArcGIS Pro
@@ -261,8 +260,7 @@ def gis_index(request: Request, f: Annotated[str | None, Query()] = None) -> Res
{base}/collections/water_chemistry. Neither QGIS nor ArcGIS Pro
can read EDR, so the layers above carry the same measurements summarised per
site instead.
-"""
- )
+""")
# ============= EOF =============================================
From 7aa174631546ae1673f7c293baf3d92039ae08f9 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Mon, 24 Aug 2026 11:07:02 -0700
Subject: [PATCH 147/151] feat(ogc): expose last_observation_date on the Group
A layers
The 11 thing-type layers carried construction and location detail but no
signal of data recency: nothing distinguished a well measured last month from
one last visited in 1994 without querying a second layer.
This adds last_observation_date to the shared thing-view template -- the UTC
date of the most recent observation recorded against the thing, or NULL where
it has none -- and rebuilds all 11 public views and their ogc_internal_
counterparts from that one template so both mounts stay identical.
Scope is the observation table, reached through the sample ->
field_activity -> field_event chain every other observation-backed view here
uses. Continuous transducer readings are deliberately excluded: they hang off
deployments rather than field events, they exist for a handful of instrumented
wells rather than for Group A generally, and a max() over the largest table in
the schema would need an index of its own to stay cheap. Wells with logger
data are served by actively_monitored_wells and the water-elevation layers.
Per-thing lookup is a LEFT JOIN LATERAL so a paginated or single-feature
request touches only the observations of the rows it returns. That join path
had no indexes at all, so the four it needs come with the migration.
The behave harness needed one fix to go with this: pygeoapi caches reflected
table models process-wide, so the scenarios that downgrade the schema under a
running app were building SELECTs naming a column the downgraded views no
longer have. The cache is now cleared wherever those scenarios move the
schema.
Co-Authored-By: Claude Opus 5
---
...dd_last_observation_date_to_thing_views.py | 239 ++++++++++++++++++
core/ogc-field-descriptions.yml | 9 +
tests/features/environment.py | 16 ++
tests/features/ogc-cleanup-sprint1.feature | 6 +-
tests/features/steps/ogc-cleanup-sprint1.py | 234 ++++++++++++++++-
5 files changed, 494 insertions(+), 10 deletions(-)
create mode 100644 alembic/versions/b8c9d0e1f2a3_add_last_observation_date_to_thing_views.py
diff --git a/alembic/versions/b8c9d0e1f2a3_add_last_observation_date_to_thing_views.py b/alembic/versions/b8c9d0e1f2a3_add_last_observation_date_to_thing_views.py
new file mode 100644
index 000000000..0c04fb2b2
--- /dev/null
+++ b/alembic/versions/b8c9d0e1f2a3_add_last_observation_date_to_thing_views.py
@@ -0,0 +1,239 @@
+"""add last_observation_date to the Group A thing views
+
+Ticket A13. The 11 thing-type layers (Group A) carry construction and location
+detail but no signal of data recency: a consumer could not tell a well measured
+last month from one last visited in 1994 without querying a second layer.
+
+This adds `last_observation_date` to the shared thing-view template -- the date
+of the most recent observation recorded against the thing, or NULL where the
+thing has no observations at all. All 11 public views and their 11
+`ogc_internal_` counterparts are rebuilt from the same template here, so the
+two mounts stay column-for-column identical.
+
+Scope of "observation": rows in the `observation` table, reached through the
+sample -> field_activity -> field_event chain that every other observation-
+backed view in this schema uses. Continuous transducer readings
+(`transducer_observation`) are deliberately *not* folded in: they live on a
+different chain (deployment -> thing), they exist for a handful of instrumented
+water wells rather than for Group A generally, and a max() over the largest
+table in the schema would need its own index on
+(deployment_id, observation_datetime) to stay cheap. Wells with logger data are
+served by ogc_actively_monitored_wells and the water-elevation layers. If
+Group A currency should later include instrument readings, that is a separate
+ticket and a separate index.
+
+The date is the UTC calendar date of the observation timestamp -- same
+convention as transducer_daily_data (v0w1x2y3z4a5) -- rather than a
+session-timezone cast, so the value does not depend on who is querying.
+
+Public views count only observations with release_status='public', matching how
+the public mount filters everything else; the internal views count all of them.
+A public well whose only observations are private therefore reads NULL on
+/ogcapi and carries a date on /ogcapi-internal.
+
+Per-thing lookup is a LEFT JOIN LATERAL rather than a grouped CTE so that a
+paginated or single-feature request touches only the observations of the rows
+it returns. That path had no indexes at all (Postgres does not index foreign
+keys on its own), so the four it needs are created here.
+
+The view bodies below are otherwise character-for-character the templates from
+f4a5b6c7d8e9 (public) and 2d3c3a268652 (internal); downgrade() restores them.
+
+Revision ID: b8c9d0e1f2a3
+Revises: 986e0eb85ab3
+Create Date: 2026-08-24 00: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 = "b8c9d0e1f2a3"
+down_revision: Union[str, Sequence[str], None] = "baba91fe5e83"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+REQUIRED_TABLES = {
+ "thing",
+ "location",
+ "location_thing_association",
+ "observation",
+ "sample",
+ "field_activity",
+ "field_event",
+}
+
+LATEST_LOCATION_CTE = """
+SELECT DISTINCT ON (lta.thing_id)
+ lta.thing_id,
+ lta.location_id,
+ lta.effective_start
+FROM location_thing_association AS lta
+WHERE lta.effective_end IS NULL
+ORDER BY lta.thing_id, lta.effective_start DESC
+""".strip()
+
+# Same 11 thing-type views as f4a5b6c7d8e9's THING_VIEWS.
+THING_VIEWS = [
+ ("water_wells", "water well"),
+ ("springs", "spring"),
+ ("diversions_surface_water", "diversion of surface water, etc."),
+ ("ephemeral_streams", "ephemeral stream"),
+ ("lakes_ponds_reservoirs", "lake, pond or reservoir"),
+ ("meteorological_stations", "meteorological station"),
+ ("other_things", "other"),
+ ("outfalls_wastewater_return_flow", "outfall of wastewater or return flow"),
+ ("perennial_streams", "perennial stream"),
+ ("rock_sample_locations", "rock sample location"),
+ ("soil_gas_sample_locations", "soil gas sample location"),
+]
+
+# (name, table, columns) for the observation chain the lateral walks
+# thing -> field_event -> field_activity -> sample -> observation.
+SUPPORTING_INDEXES = [
+ ("ix_field_event_thing_id", "field_event", "thing_id"),
+ ("ix_field_activity_field_event_id", "field_activity", "field_event_id"),
+ ("ix_sample_field_activity_id", "sample", "field_activity_id"),
+ (
+ "ix_observation_sample_id_observation_datetime",
+ "observation",
+ "sample_id, observation_datetime",
+ ),
+]
+
+
+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 _check_required_tables() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ existing_tables = set(inspector.get_table_names(schema="public"))
+ missing = REQUIRED_TABLES - existing_tables
+ if missing:
+ raise RuntimeError(
+ "Cannot add last_observation_date to the OGC thing views. "
+ f"Missing required tables: {', '.join(sorted(missing))}"
+ )
+
+
+def _create_thing_view(
+ view_id: str, thing_type: str, public_only: bool, table_prefix: str
+) -> str:
+ """The Group A view template, with last_observation_date."""
+ safe_view_id = _safe_view_id(f"{table_prefix}{view_id}")
+ escaped_thing_type = thing_type.replace("'", "''")
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ observation_release_filter = (
+ "\n AND o.release_status = 'public'" if public_only else ""
+ )
+ return f"""
+ CREATE VIEW {safe_view_id} AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ )
+ SELECT
+ t.id,
+ t.name,
+ t.first_visit_date,
+ (
+ last_obs.last_observation_datetime AT TIME ZONE 'UTC'
+ )::date AS last_observation_date,
+ t.nma_pk_welldata,
+ t.well_depth,
+ t.hole_depth,
+ t.well_casing_diameter,
+ t.well_casing_depth,
+ t.well_completion_date,
+ t.well_driller_name,
+ t.well_construction_method,
+ t.well_pump_type,
+ t.well_pump_depth,
+ t.formation_completion_code,
+ t.nma_formation_zone,
+ t.release_status,
+ l.elevation,
+ l.point
+ FROM thing AS t
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ LEFT JOIN LATERAL (
+ SELECT MAX(o.observation_datetime) AS last_observation_datetime
+ 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
+ WHERE fe.thing_id = t.id{observation_release_filter}
+ ) AS last_obs ON TRUE
+ WHERE t.thing_type = '{escaped_thing_type}'{release_filter}
+ """
+
+
+def _create_thing_view_pre_a13(
+ view_id: str, thing_type: str, public_only: bool, table_prefix: str
+) -> str:
+ """The template as it stood in f4a5b6c7d8e9/2d3c3a268652, for downgrade."""
+ safe_view_id = _safe_view_id(f"{table_prefix}{view_id}")
+ escaped_thing_type = thing_type.replace("'", "''")
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ return f"""
+ CREATE VIEW {safe_view_id} AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ )
+ SELECT
+ t.id,
+ t.name,
+ t.first_visit_date,
+ t.nma_pk_welldata,
+ t.well_depth,
+ t.hole_depth,
+ t.well_casing_diameter,
+ t.well_casing_depth,
+ t.well_completion_date,
+ t.well_driller_name,
+ t.well_construction_method,
+ t.well_pump_type,
+ t.well_pump_depth,
+ t.formation_completion_code,
+ t.nma_formation_zone,
+ t.release_status,
+ l.elevation,
+ l.point
+ FROM thing AS t
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ WHERE t.thing_type = '{escaped_thing_type}'{release_filter}
+ """
+
+
+def _rebuild_thing_views(builder) -> None:
+ for table_prefix, public_only in (("ogc_", True), ("ogc_internal_", False)):
+ for view_id, thing_type in THING_VIEWS:
+ view_name = _safe_view_id(f"{table_prefix}{view_id}")
+ op.execute(text(f"DROP VIEW IF EXISTS {view_name}"))
+ op.execute(text(builder(view_id, thing_type, public_only, table_prefix)))
+
+
+def upgrade() -> None:
+ _check_required_tables()
+
+ for index_name, table_name, columns in SUPPORTING_INDEXES:
+ op.execute(
+ text(f"CREATE INDEX IF NOT EXISTS {index_name} ON {table_name} ({columns})")
+ )
+
+ _rebuild_thing_views(_create_thing_view)
+
+
+def downgrade() -> None:
+ _rebuild_thing_views(_create_thing_view_pre_a13)
+
+ for index_name, _table_name, _columns in SUPPORTING_INDEXES:
+ op.execute(text(f"DROP INDEX IF EXISTS {index_name}"))
diff --git a/core/ogc-field-descriptions.yml b/core/ogc-field-descriptions.yml
index dd01ac954..90cf606e3 100644
--- a/core/ogc-field-descriptions.yml
+++ b/core/ogc-field-descriptions.yml
@@ -40,6 +40,15 @@ _defaults:
first_visit_date:
title: First visit date
description: Date of the earliest Bureau visit on record for this feature.
+ last_observation_date:
+ title: Last observation date
+ description: >-
+ Date of the most recent measurement recorded against this feature, as a
+ UTC calendar date. Null where no measurement is on record for it. Counts
+ readings and laboratory results held in the observation record; continuous
+ instrument readings from a deployed logger are not included, so an
+ instrumented well can carry newer data than this date shows. On the public
+ mount only measurements released to the public are counted.
nma_pk_welldata:
title: Legacy NM_Aquifer well key
description: >-
diff --git a/tests/features/environment.py b/tests/features/environment.py
index 2a7af12dc..d3c1b47cd 100644
--- a/tests/features/environment.py
+++ b/tests/features/environment.py
@@ -645,6 +645,20 @@ def _alembic_config() -> Config:
return cfg
+def reset_pygeoapi_reflection() -> None:
+ """Drop pygeoapi's process-wide cache of reflected table models.
+
+ pygeoapi.provider.sql.get_table_model is functools.cache'd, so a provider
+ keeps serving the column list it reflected the first time a collection was
+ queried. Scenarios that move the schema under a running app (the
+ @migration-mutates-schema ones) would otherwise build SELECTs naming
+ columns the downgraded views no longer have.
+ """
+ from pygeoapi.provider.sql import get_table_model
+
+ get_table_model.cache_clear()
+
+
def _initialize_test_schema() -> None:
with session_ctx() as session:
recreate_public_schema(session)
@@ -876,6 +890,7 @@ def before_scenario(context, scenario):
# Defense in depth against a previous, unrelated failure having
# already left the database below head.
command.upgrade(_alembic_config(), "head")
+ reset_pygeoapi_reflection()
def after_scenario(context, scenario):
@@ -885,6 +900,7 @@ def after_scenario(context, scenario):
# this database. Deliberately not gated on DROP_AND_REBUILD_DB,
# since these scenarios mutate schema regardless of that flag.
command.upgrade(_alembic_config(), "head")
+ reset_pygeoapi_reflection()
if not get_bool_env("DROP_AND_REBUILD_DB"):
return
diff --git a/tests/features/ogc-cleanup-sprint1.feature b/tests/features/ogc-cleanup-sprint1.feature
index bfab8bc9a..d6a823e99 100644
--- a/tests/features/ogc-cleanup-sprint1.feature
+++ b/tests/features/ogc-cleanup-sprint1.feature
@@ -203,7 +203,7 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
# A13 — Add last_observation_date column to Group A view template
# ---------------------------------------------------------------------------
- @backend @ogc-data-currency @sprint-1 @medium-priority @A13
+ @backend @ogc-data-currency @sprint-1 @medium-priority @A13 @production
Scenario: last_observation_date column is present in all Group A layers
When a client requests items from each of the following layers:
| layer-id |
@@ -221,7 +221,7 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
# other_things is not listed: it is in the Group A view template, but A18
# took it off the public catalog — it is only reachable on /ogcapi-internal.
- @backend @ogc-data-currency @sprint-1 @medium-priority @A13
+ @backend @ogc-data-currency @sprint-1 @medium-priority @A13 @production
Scenario: last_observation_date is NULL for things with no associated observations
Given monitoring locations with no linked observations exist in each of the following layers:
| layer-id |
@@ -240,7 +240,7 @@ Feature: OGC Feature Layer Cleanup — Sprint 1
# other_things is not listed: it is in the Group A view template, but A18
# took it off the public catalog — it is only reachable on /ogcapi-internal.
- @backend @ogc-data-currency @sprint-1 @medium-priority @A13
+ @backend @ogc-data-currency @sprint-1 @medium-priority @A13 @production
Scenario: Consumers can filter Group A layers by last_observation_date
Given each of the following Group A layers has features with last_observation_date values "2019-06-01" and "2023-06-01":
| layer-id |
diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py
index 8497cca13..5ccf0b229 100644
--- a/tests/features/steps/ogc-cleanup-sprint1.py
+++ b/tests/features/steps/ogc-cleanup-sprint1.py
@@ -14,12 +14,14 @@
# limitations under the License.
# ===============================================================================
"""Step definitions for A1 (public release_status filter on ogc_* views),
-A2 (OGC server metadata placeholders) and A11 (authenticated internal OGC
-mount at /ogcapi-internal).
-
-Only the @A1-, @A2- and @A11-tagged scenarios in ogc-cleanup-sprint1.feature
-are implemented here. The other ~8 tickets sharing that feature file have no
-steps yet and stay undefined/dormant, per those tickets' plans.
+A2 (OGC server metadata placeholders), A11 (authenticated internal OGC
+mount at /ogcapi-internal) and A13 (last_observation_date on the Group A
+view template).
+
+Only the @A1-, @A2-, @A11- and @A13-tagged scenarios in
+ogc-cleanup-sprint1.feature are implemented here. The other tickets sharing
+that feature file have no steps yet and stay undefined/dormant, per those
+tickets' plans.
"""
import importlib
@@ -62,7 +64,7 @@
)
from db.engine import session_ctx
from tests import get_parameter_id
-from tests.features.environment import _alembic_config
+from tests.features.environment import _alembic_config, reset_pygeoapi_reflection
# Revision immediately before this ticket's schema migration -- re-verify
# with `alembic heads`/`alembic history` if this file is revisited later,
@@ -649,6 +651,7 @@ def _seed_already_consistent_layers(session):
@given("the following layers were already filtering correctly before the migration:")
def step_given_already_consistent_layers(context):
command.downgrade(_alembic_config(), PRE_A1_REVISION)
+ reset_pygeoapi_reflection()
with session_ctx() as session:
_seed_already_consistent_layers(session)
session.commit()
@@ -664,6 +667,7 @@ def step_given_already_consistent_layers(context):
@when("the Sprint 1 migration is applied")
def step_when_sprint1_migration_is_applied(context):
command.upgrade(_alembic_config(), "head")
+ reset_pygeoapi_reflection()
@then("each of those layers returns the same feature count as before the migration")
@@ -1023,4 +1027,220 @@ def step_then_terms_of_service_resolves(context):
), f"{terms_url!r} resolved but does not look like the disclaimer page"
+# ---------------------------------------------------------------------------
+# A13 -- last_observation_date on the Group A view template
+# ---------------------------------------------------------------------------
+
+# Every Group A layer the A13 scenarios name, plus water_wells, which is not in
+# SIMPLE_THING_TYPE_LAYERS because the A1 scenarios seed it with a full
+# observation/chemistry chain rather than a bare Location + Thing.
+A13_LAYER_THING_TYPES = {"water_wells": "water well", **dict(SIMPLE_THING_TYPE_LAYERS)}
+
+# Dates the filter scenario splits on: one comfortably before its 2021-01-01
+# cutoff, one comfortably after.
+A13_STALE_DATE = "2019-06-01"
+A13_RECENT_DATE = "2023-06-01"
+
+
+def _seed_thing_with_observation(session, thing_type, name, observation_date=None):
+ """A public thing of `thing_type`, optionally with one public observation.
+
+ `observation_date` is a plain YYYY-MM-DD string; it is stored at midday UTC
+ so the view's UTC-date cast cannot land on the neighbouring day.
+ """
+ thing = _seed_thing_with_location(session, thing_type, "public", name)
+ if observation_date is None:
+ return thing
+
+ field_event = FieldEvent(
+ thing_id=thing.id,
+ event_date=f"{observation_date}T12:00:00Z",
+ notes="A13 behave seed field event",
+ release_status="public",
+ )
+ session.add(field_event)
+ session.commit()
+
+ field_activity = FieldActivity(
+ field_event_id=field_event.id,
+ activity_type="groundwater level",
+ notes="A13 behave seed field activity",
+ release_status="public",
+ )
+ session.add(field_activity)
+ session.commit()
+
+ sample = Sample(
+ field_activity_id=field_activity.id,
+ sample_date=f"{observation_date}T12:00:00Z",
+ sample_name=f"A13 sample {thing.id}",
+ sample_matrix="water",
+ sample_method="Steel-tape measurement",
+ qc_type="Normal",
+ notes="A13 behave seed sample",
+ release_status="public",
+ )
+ session.add(sample)
+ session.commit()
+
+ observation = Observation(
+ observation_datetime=f"{observation_date}T12:00:00Z",
+ sample_id=sample.id,
+ parameter_id=get_parameter_id("groundwater level", "Field Parameter"),
+ release_status="public",
+ value=12.0,
+ unit="ft",
+ measuring_point_height=1.0,
+ groundwater_level_reason="Water level not affected",
+ )
+ session.add(observation)
+ session.commit()
+
+ return thing
+
+
+def _get_item(context, layer_id, feature_id):
+ response = context.client.get(f"/ogcapi/collections/{layer_id}/items/{feature_id}")
+ assert response.status_code == 200, (
+ f"Unexpected status {response.status_code} for {layer_id}/{feature_id}: "
+ f"{response.text}"
+ )
+ return response.json()
+
+
+@when("a client requests items from each of the following layers:")
+def step_when_client_requests_items_from_layers(context):
+ context.layer_responses = {}
+ for row in context.table:
+ layer_id = row["layer-id"].strip()
+ context.layer_responses[layer_id] = _get_items(context, layer_id)
+
+
+@then("each feature includes a last_observation_date property")
+def step_then_each_feature_includes_last_observation_date(context):
+ for layer_id, payload in context.layer_responses.items():
+ # Several Group A thing types carry no seeded rows in the behave
+ # database, and an empty feature list would let a missing column pass
+ # unnoticed -- so the layer's own queryables are checked as well.
+ queryables = context.client.get(f"/ogcapi/collections/{layer_id}/queryables")
+ assert queryables.status_code == 200, (
+ f"queryables for {layer_id} returned {queryables.status_code}: "
+ f"{queryables.text}"
+ )
+ advertised = queryables.json().get("properties", {})
+ assert "last_observation_date" in advertised, (
+ f"{layer_id} does not advertise last_observation_date: "
+ f"{sorted(advertised)}"
+ )
+
+ for feature in payload["features"]:
+ assert "last_observation_date" in feature["properties"], (
+ f"{layer_id} feature {feature.get('id')} has no "
+ f"last_observation_date property: {sorted(feature['properties'])}"
+ )
+
+
+@given(
+ "monitoring locations with no linked observations exist in each of the following layers:"
+)
+def step_given_things_without_observations(context):
+ context.a13_unobserved_ids = {}
+ with session_ctx() as session:
+ for row in context.table:
+ layer_id = row["layer-id"].strip()
+ thing_type = A13_LAYER_THING_TYPES[layer_id]
+ thing = _seed_thing_with_observation(
+ session, thing_type, f"A13 unobserved {layer_id}"
+ )
+ context.a13_unobserved_ids[layer_id] = thing.id
+
+
+@when("a client requests those features")
+def step_when_client_requests_those_features(context):
+ context.a13_unobserved_features = {
+ layer_id: _get_item(context, layer_id, feature_id)
+ for layer_id, feature_id in context.a13_unobserved_ids.items()
+ }
+
+
+@then("each feature's last_observation_date property is null")
+def step_then_last_observation_date_is_null(context):
+ for layer_id, feature in context.a13_unobserved_features.items():
+ value = feature["properties"]["last_observation_date"]
+ assert value is None, (
+ f"{layer_id} feature {feature.get('id')} has last_observation_date "
+ f"{value!r}; a thing with no observations must read null"
+ )
+
+
+@given(
+ "each of the following Group A layers has features with last_observation_date "
+ 'values "{stale_date}" and "{recent_date}":'
+)
+def step_given_layers_with_stale_and_recent_observations(
+ context, stale_date, recent_date
+):
+ context.a13_stale_ids = {}
+ context.a13_recent_ids = {}
+ with session_ctx() as session:
+ for row in context.table:
+ layer_id = row["layer-id"].strip()
+ thing_type = A13_LAYER_THING_TYPES[layer_id]
+ stale = _seed_thing_with_observation(
+ session, thing_type, f"A13 stale {layer_id}", stale_date
+ )
+ recent = _seed_thing_with_observation(
+ session, thing_type, f"A13 recent {layer_id}", recent_date
+ )
+ context.a13_stale_ids[layer_id] = stale.id
+ context.a13_recent_ids[layer_id] = recent.id
+
+
+@when("a client requests items from each of those layers with filter")
+def step_when_client_requests_layers_with_filter(context):
+ cql = context.text.strip()
+ context.layer_responses = {}
+ for layer_id in context.a13_recent_ids:
+ response = context.client.get(
+ f"/ogcapi/collections/{layer_id}/items",
+ params={"filter": cql, "filter-lang": "cql2-text", "limit": 200},
+ )
+ assert response.status_code == 200, (
+ f"Filtered request on {layer_id} returned {response.status_code}: "
+ f"{response.text}"
+ )
+ context.layer_responses[layer_id] = response.json()
+
+
+@then(
+ 'only features with a last_observation_date of "{recent_date}" are returned '
+ "from each layer"
+)
+def step_then_only_recent_features_returned(context, recent_date):
+ cutoff = date.fromisoformat("2021-01-01")
+ for layer_id, payload in context.layer_responses.items():
+ returned_ids = _layer_feature_ids(payload)
+ recent_id = context.a13_recent_ids[layer_id]
+ stale_id = context.a13_stale_ids[layer_id]
+
+ assert (
+ recent_id in returned_ids
+ ), f"{layer_id} dropped its {recent_date} feature (id={recent_id})"
+ assert stale_id not in returned_ids, (
+ f"{layer_id} returned its {A13_STALE_DATE} feature (id={stale_id}) "
+ "through a filter that excludes it"
+ )
+
+ for feature in payload["features"]:
+ value = feature["properties"]["last_observation_date"]
+ assert value is not None, (
+ f"{layer_id} feature {feature.get('id')} passed the filter with "
+ "a null last_observation_date"
+ )
+ assert date.fromisoformat(value[:10]) > cutoff, (
+ f"{layer_id} feature {feature.get('id')} has last_observation_date "
+ f"{value!r}, which the filter should have excluded"
+ )
+
+
# ============= EOF =============================================
From 8a0c3cef8e18758f7c019f37f3869c5d6f4b48a6 Mon Sep 17 00:00:00 2001
From: jakeross
Date: Mon, 24 Aug 2026 11:31:30 -0700
Subject: [PATCH 148/151] feat(ogc): publish a well water-column layer
A well's construction record says how deep the hole goes and its water-level
record says how far down the water sits, but nothing in the catalogue
published the difference -- the standing column of water in the well, which is
the number that says whether a well still holds usable water.
ogc_well_water_column (and its unfiltered ogc_internal_ twin) carries one row
per water well with the same well and location fields the water_wells layer
publishes, plus four derived depths in feet: the well depth less the latest
depth to water, less the mean depth to water, less the shallowest reading on
record, and less the deepest. Shallowest water leaves the most in the well and
deepest the least, hence maximum/minimum naming the column rather than the
reading.
Readings are manual groundwater-level observations taken below ground surface
as value minus measuring-point height, the same convention water_well_summary
and latest_depth_to_water_wells already use, so the three layers cannot
disagree about what a depth to water is. Continuous transducer readings are
not counted.
Negative results are clamped to zero: a reading deeper than the recorded well
depth is a contradiction between two records rather than a well holding
negative water. The contradiction itself stays visible in water_well_summary,
which publishes the raw shallowest and deepest readings beside the well depth.
Wells with no recorded depth, or no usable reading, are left out -- all four
columns would be NULL and the row would say nothing.
Materialized, because every column but the latest aggregates a well's whole
reading history. The nightly pg_cron job refreshes every matview in the public
schema by name, so the two are picked up with no change to the schedule, and
both carry a unique index on id so a manual refresh can run CONCURRENTLY.
Co-Authored-By: Claude Opus 5
---
...1f2a3b4_add_well_water_column_ogc_views.py | 212 ++++++++++++++++++
core/ogc-field-descriptions.yml | 34 +++
core/pygeoapi-config-internal.yml | 42 ++++
core/pygeoapi-config.yml | 42 ++++
tests/test_ogc.py | 105 +++++++++
5 files changed, 435 insertions(+)
create mode 100644 alembic/versions/c9d0e1f2a3b4_add_well_water_column_ogc_views.py
diff --git a/alembic/versions/c9d0e1f2a3b4_add_well_water_column_ogc_views.py b/alembic/versions/c9d0e1f2a3b4_add_well_water_column_ogc_views.py
new file mode 100644
index 000000000..315146f31
--- /dev/null
+++ b/alembic/versions/c9d0e1f2a3b4_add_well_water_column_ogc_views.py
@@ -0,0 +1,212 @@
+"""add the well water-column OGC layer
+
+A water well's construction record says how deep the hole goes; its
+groundwater-level record says how far down the water sits. The difference --
+the standing column of water inside the well -- is the number that says whether
+a well still has usable water in it, and nothing in the catalogue published it.
+
+This creates ogc_well_water_column (public) and ogc_internal_well_water_column
+(unfiltered), one row per water well, carrying the same well and location
+fields the water_wells layer publishes plus four derived depths, all in feet:
+
+ water_column_latest well depth minus the most recent depth to water
+ water_column_average well depth minus the mean depth to water
+ water_column_maximum well depth minus the shallowest depth to water
+ water_column_minimum well depth minus the deepest depth to water
+
+Shallowest water gives the largest column and deepest water the smallest, hence
+the maximum/minimum naming: these are the extremes of the water column itself,
+not of the readings behind them.
+
+Readings are manual groundwater-level observations, taken below ground surface
+as (value - measuring_point_height) with a missing height treated as ground
+level -- the same convention as ogc_water_well_summary and
+ogc_latest_depth_to_water_wells, so the three layers cannot disagree about what
+a depth to water is. Continuous transducer readings are not included.
+
+Negative results are clamped to zero. A reading deeper than the recorded well
+depth is a contradiction between two records rather than a well holding
+negative water, and the clamp keeps consumers from having to special-case it;
+the contradiction itself stays visible in water_well_summary, which publishes
+the raw shallowest and deepest readings next to the well depth.
+
+Rows are restricted to wells that have both a well depth and at least one
+usable reading -- without either, all four columns would be NULL and the row
+would say nothing.
+
+Materialized, because every column but the latest one aggregates a well's
+entire reading history. The nightly pg_cron job (b6c7d8e9f0a1) refreshes every
+matview in the public schema by name, so these two are picked up with no change
+to the schedule. Both carry a unique index on id so the refresh can also be run
+CONCURRENTLY by hand (`oco refresh-matview --concurrently`).
+
+Revision ID: c9d0e1f2a3b4
+Revises: b8c9d0e1f2a3
+Create Date: 2026-08-24 00: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 = "c9d0e1f2a3b4"
+down_revision: Union[str, Sequence[str], None] = "b8c9d0e1f2a3"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+REQUIRED_TABLES = {
+ "thing",
+ "location",
+ "location_thing_association",
+ "observation",
+ "sample",
+ "field_activity",
+ "field_event",
+}
+
+LATEST_LOCATION_CTE = """
+SELECT DISTINCT ON (lta.thing_id)
+ lta.thing_id,
+ lta.location_id,
+ lta.effective_start
+FROM location_thing_association AS lta
+WHERE lta.effective_end IS NULL
+ORDER BY lta.thing_id, lta.effective_start DESC
+""".strip()
+
+VIEWS = [
+ ("ogc_well_water_column", True),
+ ("ogc_internal_well_water_column", False),
+]
+
+
+def _safe_relation_name(name: str) -> str:
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
+ raise ValueError(f"Unsafe relation name: {name!r}")
+ return name
+
+
+def _check_required_tables() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ existing_tables = set(inspector.get_table_names(schema="public"))
+ missing = REQUIRED_TABLES - existing_tables
+ if missing:
+ raise RuntimeError(
+ "Cannot create the well water-column views. "
+ f"Missing required tables: {', '.join(sorted(missing))}"
+ )
+
+
+def _create_well_water_column_view(view_name: str, public_only: bool) -> str:
+ safe_view_name = _safe_relation_name(view_name)
+ release_filter = " AND t.release_status = 'public'" if public_only else ""
+ observation_release_filter = (
+ "\n AND o.release_status = 'public'" if public_only else ""
+ )
+ return f"""
+ CREATE MATERIALIZED VIEW {safe_view_name} AS
+ WITH latest_location AS (
+{LATEST_LOCATION_CTE}
+ ),
+ wl_obs AS (
+ SELECT
+ fe.thing_id,
+ o.id AS observation_id,
+ o.observation_datetime,
+ (o.value - COALESCE(o.measuring_point_height, 0)) AS water_level
+ FROM observation AS o
+ JOIN sample AS s ON s.id = o.sample_id
+ JOIN field_activity AS fa ON fa.id = s.field_activity_id
+ JOIN field_event AS fe ON fe.id = fa.field_event_id
+ JOIN thing AS t ON t.id = fe.thing_id
+ WHERE
+ t.thing_type = 'water well'
+ AND fa.activity_type = 'groundwater level'
+ AND o.value IS NOT NULL
+ AND o.observation_datetime IS NOT NULL{observation_release_filter}
+ ),
+ wl_agg AS (
+ SELECT
+ w.thing_id,
+ AVG(w.water_level) AS avg_water_level,
+ MIN(w.water_level) AS min_water_level,
+ MAX(w.water_level) AS max_water_level
+ FROM wl_obs AS w
+ GROUP BY w.thing_id
+ ),
+ wl_last AS (
+ SELECT
+ ranked.thing_id,
+ ranked.water_level AS last_water_level
+ FROM (
+ SELECT
+ w.thing_id,
+ w.water_level,
+ ROW_NUMBER() OVER (
+ PARTITION BY w.thing_id
+ ORDER BY w.observation_datetime DESC, w.observation_id DESC
+ ) AS rn
+ FROM wl_obs AS w
+ ) AS ranked
+ WHERE ranked.rn = 1
+ )
+ SELECT
+ t.id AS id,
+ t.name,
+ t.first_visit_date,
+ t.nma_pk_welldata,
+ t.well_depth,
+ t.hole_depth,
+ t.well_casing_diameter,
+ t.well_casing_depth,
+ t.well_completion_date,
+ t.well_driller_name,
+ t.well_construction_method,
+ t.well_pump_type,
+ t.well_pump_depth,
+ t.formation_completion_code,
+ t.nma_formation_zone,
+ t.release_status,
+ GREATEST(t.well_depth - wl.last_water_level, 0) AS water_column_latest,
+ GREATEST(t.well_depth - wa.avg_water_level, 0) AS water_column_average,
+ -- The shallowest reading leaves the most water in the well, the
+ -- deepest the least, so min/max swap sides here.
+ GREATEST(t.well_depth - wa.min_water_level, 0) AS water_column_maximum,
+ GREATEST(t.well_depth - wa.max_water_level, 0) AS water_column_minimum,
+ l.elevation,
+ l.point
+ FROM thing AS t
+ JOIN latest_location AS ll ON ll.thing_id = t.id
+ JOIN location AS l ON l.id = ll.location_id
+ JOIN wl_agg AS wa ON wa.thing_id = t.id
+ JOIN wl_last AS wl ON wl.thing_id = t.id
+ WHERE
+ t.thing_type = 'water well'
+ AND t.well_depth IS NOT NULL{release_filter}
+ """
+
+
+def upgrade() -> None:
+ _check_required_tables()
+
+ for view_name, public_only in VIEWS:
+ safe_view_name = _safe_relation_name(view_name)
+ op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {safe_view_name}"))
+ op.execute(text(_create_well_water_column_view(view_name, public_only)))
+ # Unique index required for REFRESH MATERIALIZED VIEW CONCURRENTLY.
+ op.execute(
+ text(
+ f"CREATE UNIQUE INDEX ix_{safe_view_name}_id "
+ f"ON {safe_view_name} (id)"
+ )
+ )
+
+
+def downgrade() -> None:
+ for view_name, _public_only in VIEWS:
+ safe_view_name = _safe_relation_name(view_name)
+ op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {safe_view_name}"))
diff --git a/core/ogc-field-descriptions.yml b/core/ogc-field-descriptions.yml
index 90cf606e3..c462b6dd9 100644
--- a/core/ogc-field-descriptions.yml
+++ b/core/ogc-field-descriptions.yml
@@ -244,6 +244,40 @@ project_areas:
Kind of grouping the record represents, such as a project or a
geographic area.
+well_water_column:
+ water_column_latest:
+ title: Water column, latest reading
+ description: >-
+ Standing water in the well at the most recent measurement: the well's
+ depth less that reading's depth to water. Reported as zero where the
+ reading is deeper than the recorded well depth.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT
+ x-ogc-unitLang: QUDT
+ water_column_average:
+ title: Water column, average reading
+ description: >-
+ Standing water the well holds on average: the well's depth less the mean
+ depth to water across every reading on record. Each reading counts once,
+ however unevenly spaced in time they are. Reported as zero where the mean
+ reading is deeper than the recorded well depth.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT
+ x-ogc-unitLang: QUDT
+ water_column_maximum:
+ title: Water column, fullest on record
+ description: >-
+ The most standing water the well is known to have held: the well's depth
+ less the shallowest depth to water on record.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT
+ x-ogc-unitLang: QUDT
+ water_column_minimum:
+ title: Water column, emptiest on record
+ description: >-
+ The least standing water the well is known to have held: the well's depth
+ less the deepest depth to water on record. Reported as zero where that
+ reading is deeper than the recorded well depth.
+ x-ogc-unit: https://qudt.org/vocab/unit/FT
+ x-ogc-unitLang: QUDT
+
water_well_summary:
elevation_method:
title: Elevation method
diff --git a/core/pygeoapi-config-internal.yml b/core/pygeoapi-config-internal.yml
index 2396539e1..e62723e0c 100644
--- a/core/pygeoapi-config-internal.yml
+++ b/core/pygeoapi-config-internal.yml
@@ -284,6 +284,48 @@ resources:
table: ogc_internal_water_well_summary
geom_field: point
+ well_water_column:
+ type: collection
+ title: Well Water Column (Water Wells)
+ description: >-
+ One row per water well, reporting how much standing water the well
+ holds: the well's depth minus its depth to water, in feet, worked out
+ four ways -- from the most recent reading, from the average of every
+ reading, from the shallowest water level on record (the fullest the
+ well has been) and from the deepest (the emptiest). Depths to water are
+ manual readings below ground surface -- the measured depth minus the
+ height of the measuring point above ground, with readings that have no
+ recorded measuring-point height treated as taken at ground level.
+ Continuous logger readings are not counted. A reading deeper than the
+ recorded well depth would give a negative column and is reported as
+ zero instead; water_well_summary publishes the raw shallowest and
+ deepest readings beside the well depth if you need to see that
+ contradiction. Wells with no depth on record, or no usable reading, are
+ left out. Each row also carries the well's construction record and
+ surveyed ground elevation. Use it to judge remaining water column and
+ how far it has swung over the well's history.
+ keywords: [
+ water-wells, water-column, groundwater-level, well-depth,
+ depth-to-water, saturated-thickness, drawdown
+ ]
+ 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: core.feature_provider.DescribedPostgreSQLProvider
+ 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_internal_well_water_column
+ geom_field: point
+
major_chemistry_results:
type: collection
title: Major Chemistry (Water Wells)
diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml
index 093cd8b84..0348aa048 100644
--- a/core/pygeoapi-config.yml
+++ b/core/pygeoapi-config.yml
@@ -194,6 +194,48 @@ resources:
table: ogc_water_well_summary
geom_field: point
+ well_water_column:
+ type: collection
+ title: Well Water Column (Water Wells)
+ description: >-
+ One row per water well, reporting how much standing water the well
+ holds: the well's depth minus its depth to water, in feet, worked out
+ four ways -- from the most recent reading, from the average of every
+ reading, from the shallowest water level on record (the fullest the
+ well has been) and from the deepest (the emptiest). Depths to water are
+ manual readings below ground surface -- the measured depth minus the
+ height of the measuring point above ground, with readings that have no
+ recorded measuring-point height treated as taken at ground level.
+ Continuous logger readings are not counted. A reading deeper than the
+ recorded well depth would give a negative column and is reported as
+ zero instead; water_well_summary publishes the raw shallowest and
+ deepest readings beside the well depth if you need to see that
+ contradiction. Wells with no depth on record, or no usable reading, are
+ left out. Each row also carries the well's construction record and
+ surveyed ground elevation. Use it to judge remaining water column and
+ how far it has swung over the well's history.
+ keywords: [
+ water-wells, water-column, groundwater-level, well-depth,
+ depth-to-water, saturated-thickness, drawdown
+ ]
+ 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: core.feature_provider.DescribedPostgreSQLProvider
+ 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_well_water_column
+ geom_field: point
+
major_chemistry_results:
type: collection
title: Major Chemistry (Water Wells)
diff --git a/tests/test_ogc.py b/tests/test_ogc.py
index 86265e5a8..18c5fea74 100644
--- a/tests/test_ogc.py
+++ b/tests/test_ogc.py
@@ -458,6 +458,109 @@ def test_ogc_water_elevation_wells_normalizes_meter_observations_to_feet(
session.commit()
+def _seed_water_levels(session, sample, readings, release_status="public"):
+ """readings: (day, value, measuring_point_height) -> depth to water is
+ value - measuring_point_height, matching the layer's convention."""
+ from db import Observation
+ from tests import get_parameter_id
+
+ observations = []
+ for day, value, measuring_point_height in readings:
+ observation = Observation(
+ observation_datetime=datetime(2025, 1, day, 12, 0, 0),
+ sample_id=sample.id,
+ parameter_id=get_parameter_id("groundwater level", "Field Parameter"),
+ release_status=release_status,
+ value=value,
+ unit="ft",
+ measuring_point_height=measuring_point_height,
+ groundwater_level_reason="Water level not affected",
+ )
+ session.add(observation)
+ observations.append(observation)
+ session.commit()
+ return observations
+
+
+def test_ogc_well_water_column_computes_the_four_depths(
+ water_well_thing, groundwater_level_sample
+):
+ # The well is 10 ft deep. Readings give depths to water of 5, 2 and 14 ft
+ # below ground surface, the last one deeper than the well itself.
+ with session_ctx() as session:
+ observations = _seed_water_levels(
+ session,
+ groundwater_level_sample,
+ [(1, 6.0, 1.0), (2, 3.0, 1.0), (3, 15.0, 1.0)],
+ )
+ session.execute(text("REFRESH MATERIALIZED VIEW ogc_well_water_column"))
+ session.commit()
+
+ row = session.execute(
+ text(
+ "SELECT water_column_latest, water_column_average, "
+ "water_column_maximum, water_column_minimum "
+ "FROM ogc_well_water_column WHERE id = :thing_id"
+ ),
+ {"thing_id": water_well_thing.id},
+ ).one()
+
+ # Latest reading sits 4 ft below the bottom of the well, so the
+ # negative column is published as zero rather than -4.
+ assert float(row.water_column_latest) == 0.0
+ # Mean depth to water is (5 + 2 + 14) / 3 = 7 ft.
+ assert abs(float(row.water_column_average) - 3.0) < 1e-9
+ # Shallowest water (2 ft) leaves the most in the well.
+ assert float(row.water_column_maximum) == 8.0
+ # Deepest water (14 ft) leaves none, clamped from -4.
+ assert float(row.water_column_minimum) == 0.0
+
+ for observation in observations:
+ session.delete(observation)
+ session.commit()
+ session.execute(text("REFRESH MATERIALIZED VIEW ogc_well_water_column"))
+ session.commit()
+
+
+def test_ogc_well_water_column_counts_private_readings_only_on_the_internal_view(
+ water_well_thing, groundwater_level_sample
+):
+ with session_ctx() as session:
+ observations = _seed_water_levels(
+ session,
+ groundwater_level_sample,
+ [(1, 6.0, 1.0)],
+ release_status="private",
+ )
+ for relation in ("ogc_well_water_column", "ogc_internal_well_water_column"):
+ session.execute(text(f"REFRESH MATERIALIZED VIEW {relation}"))
+ session.commit()
+
+ # No public reading, so the well has nothing to report publicly and
+ # drops out of the layer entirely.
+ public = session.execute(
+ text("SELECT COUNT(*) FROM ogc_well_water_column WHERE id = :thing_id"),
+ {"thing_id": water_well_thing.id},
+ ).scalar()
+ assert public == 0
+
+ internal = session.execute(
+ text(
+ "SELECT water_column_latest FROM ogc_internal_well_water_column "
+ "WHERE id = :thing_id"
+ ),
+ {"thing_id": water_well_thing.id},
+ ).scalar()
+ assert float(internal) == 5.0
+
+ for observation in observations:
+ session.delete(observation)
+ session.commit()
+ for relation in ("ogc_well_water_column", "ogc_internal_well_water_column"):
+ session.execute(text(f"REFRESH MATERIALIZED VIEW {relation}"))
+ session.commit()
+
+
def test_ogc_actively_monitored_wells_exposes_water_level_network_group_wells(
water_well_thing,
groundwater_level_observation,
@@ -704,6 +807,7 @@ def test_ogc_collections(ogc_client):
"depth_to_water_trend_wells",
"water_elevation_wells",
"water_well_summary",
+ "well_water_column",
"major_chemistry_results",
"minor_chemistry_wells",
"actively_monitored_wells",
@@ -730,6 +834,7 @@ def test_ogc_new_collection_items_endpoints(ogc_client):
"depth_to_water_trend_wells",
"water_elevation_wells",
"water_well_summary",
+ "well_water_column",
"major_chemistry_results",
"minor_chemistry_wells",
"actively_monitored_wells",
From a51415d2c7aad1e8c0429d8d7c1563d120b18cea Mon Sep 17 00:00:00 2001
From: jakeross
Date: Mon, 24 Aug 2026 15:58:10 -0700
Subject: [PATCH 149/151] chore: sync staging release-please manifest to v1.2.1
---
.release-please-manifest.staging.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.release-please-manifest.staging.json b/.release-please-manifest.staging.json
index c3f146397..41ea87d76 100644
--- a/.release-please-manifest.staging.json
+++ b/.release-please-manifest.staging.json
@@ -1,3 +1,3 @@
{
- ".": "1.2.0"
+ ".": "1.2.1"
}
From 0a46106a22014c32ecf48a6d82b4edf53cbac1b4 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 24 Aug 2026 23:04:54 +0000
Subject: [PATCH 150/151] chore(staging): release 1.3.0-rc
---
.release-please-manifest.staging.json | 2 +-
CHANGELOG-rc.md | 119 ++++++++++++++++++++++++++
2 files changed, 120 insertions(+), 1 deletion(-)
diff --git a/.release-please-manifest.staging.json b/.release-please-manifest.staging.json
index 41ea87d76..63ae49803 100644
--- a/.release-please-manifest.staging.json
+++ b/.release-please-manifest.staging.json
@@ -1,3 +1,3 @@
{
- ".": "1.2.1"
+ ".": "1.3.0-rc"
}
diff --git a/CHANGELOG-rc.md b/CHANGELOG-rc.md
index ffaf3b394..ad8001036 100644
--- a/CHANGELOG-rc.md
+++ b/CHANGELOG-rc.md
@@ -1,5 +1,124 @@
# Changelog
+## [1.3.0-rc](https://github.com/DataIntegrationGroup/OcotilloAPI/compare/v1.2.1...v1.3.0-rc) (2026-08-24)
+
+
+### Features
+
+* **chemistry:** report which legacy table a result came from ([67b522c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/67b522ca6bc26b8995a0e2d2d7e24b3dac8bbba8))
+* **chemistry:** serve legacy water chemistry over REST ([dcb0cb1](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/dcb0cb198b9faa1e8d41c203c8fff3c5b831580a))
+* **chemistry:** serve legacy water chemistry over REST ([97d7f9f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/97d7f9fbff533dd19c933148decf18c9acb3cf45))
+* **data-migrations:** publish existing project_areas ([70c688f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/70c688f49528542c6eded1974a9f4cfaae8eb2cd))
+* **geothermal:** add /thing/geothermal-well endpoints ([c60faf8](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c60faf8b56504e07428bc344c43d2e635483a64b))
+* **geothermal:** add /thing/geothermal-well endpoints ([83e6604](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/83e66040a2977e95a429360c0ba11b8ba6eb3327))
+* **geothermal:** free-text search on the well list endpoint ([7e0a259](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7e0a2597cd63fedea065cda845d13fb7306358cb))
+* **geothermal:** free-text search on the well list endpoint ([e5748a5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e5748a540aec737136ac13795c2f9f917f198c37))
+* **geothermal:** normalize OGC view temperatures to Celsius ([c6cfb7e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c6cfb7e1d22536b6e0b74ba53499c7d75c76f6b6))
+* **geothermal:** normalize OGC view temperatures to Celsius ([013fd75](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/013fd7554702f9e4c13ebdf006cd726f4b957c8e))
+* **gis:** generate shareable QGIS and ArcGIS Pro artifacts ([04fafce](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/04fafcea29ff5278ac224eff4f9dc7181e5000e0))
+* **gis:** serve the artifact catalogue as JSON for frontend clients ([3aa6611](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3aa6611ce96bf87722143fe304aa5d7f66bd1fd3))
+* **ingestion:** add automated ingestion pipeline foundations ([055b51e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/055b51ea9a26fb9b5df6d536ff18cc4d6f4b58eb))
+* **ingestion:** add raw-zone infrastructure and database connectivity ([475841d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/475841da0b0cbb3e7f4fdd91974318de34fc445e))
+* **ingestion:** add the Diver-HUB client and correct the source mapping ([e01fd8c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e01fd8c34f3eccc7f65000e54118042033d547c3))
+* **ingestion:** add the shared backfill primitives ([279ff6e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/279ff6e0ea33b89db5f04d83a34653bafd0cc415))
+* **ingestion:** add the shared backfill primitives ([6d1ced3](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/6d1ced36e8965ed313f7b10b162cb229716adc87))
+* **ingestion:** add the transducer unique constraint and upsert loader ([6f3232d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/6f3232d8ba350bb0228bf5c6f8568cf1411b2a83))
+* **ingestion:** add the transducer unique constraint and upsert loader ([31e0644](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/31e06448988f0edff6a6c19b98caa61c4a385645))
+* **ingestion:** add the Van Essen domain rules and adapter ([34c4b1f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/34c4b1f281d8c36ae3eee55f7d3640c36c3beedf))
+* **ingestion:** add the Van Essen domain rules and adapter ([91f4504](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/91f450436bab030a9e834271169c68c6bfd25cae))
+* **ingestion:** derive the watermark from Postgres ([87b8e9c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/87b8e9c24324e6abde5cbd0c415b4ea87e7c0bf3))
+* **ingestion:** derive the watermark from Postgres ([a0af312](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a0af3127d52d97c2baa0f795b0b203c362cbd891))
+* **ingestion:** land San Acacia locations and readings in the raw zone ([6c9ab70](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/6c9ab7097b234d37e15fb71e29f42a0cd69422cb))
+* **ingestion:** reconcile San Acacia points against Ocotillo wells ([fecbae8](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fecbae8f4c77618cde0a1d8b48ccfa055643e3d1))
+* **ingestion:** reconcile San Acacia points against Ocotillo wells ([c20e2e3](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c20e2e39795f161d6ae3405df3cc9f30e506e131))
+* **ingestion:** resolve the datum enum and the source unit ([d10b2b4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d10b2b457b21aa36d135eef3245fbd3c49d822d0))
+* **ingestion:** scaffold the automated_ingestion Dagster code location ([0a2109e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0a2109e8a514c618d794cc317665b33777135ac0))
+* **ingestion:** schedule the San Acacia ingest weekly ([8c02977](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8c029773cb6a0d4e2d3276e37b63c5a3ddd80da1))
+* **ingestion:** schedule the San Acacia ingest weekly ([e4b1880](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e4b1880c89d5c93369b3ed2b6469614376c9965c))
+* **ingestion:** wire the loader end to end ([3e38614](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3e38614bebab7d5e69707f1bb62ababb859c3c86))
+* **ingestion:** wire the loader end to end ([113fdd6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/113fdd6f4d8ef1f786c9bc8dfd27650d2b2f5ac2))
+* **lexicon:** add new organizations to lexicon ([b30bea8](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b30bea8b58242c805f9c65f41bf7b758b29ae9f7))
+* **lexicon:** add organization and sort organization category alphabetically ([1057c86](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1057c86397902ac94089bf1f7a8fa229049f08ea))
+* **lexicon:** add organization and sort organization category alphabetically ([21090b5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/21090b50e90eea9d52295cc68b18c2926e9dd9a5))
+* **ogc:** add authenticated internal OGC mount (BDMS-985 A11) ([ce45b91](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ce45b919db9004aeb1bcac065b315044986eb2c9))
+* **ogc:** add public data disclaimer page ([72f26a5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/72f26a56c349d59e16a3110eb54cec035eb4ee54))
+* **ogc:** add the field-description source of truth ([832b659](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/832b659b18b3c186bfa7b186df6fa44b0b616276))
+* **ogc:** carry field descriptions onto /queryables ([1d41b56](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1d41b5654aca4a27ed9b11a37d43f7db3829066e))
+* **ogc:** document EDR parameters and cover the lot with tests ([ac0513d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ac0513d36431bb3bcb64185dc98a0a551f1ef15e))
+* **ogc:** expose last_observation_date on the Group A layers ([7aa1746](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7aa174631546ae1673f7c293baf3d92039ae08f9))
+* **ogc:** filter ogc_* views to public records ([dbc760b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/dbc760bf91bb072b8402a8f52190e1f1411e432f))
+* **ogc:** make /ogcapi-internal usable from ArcGIS Pro and QGIS ([cfdf146](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/cfdf1461d7f60b4ad31c1791a647a5c5a8910035))
+* **ogc:** make /ogcapi-internal usable from ArcGIS Pro and QGIS ([4c0099e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4c0099eb2f745312d5a43f5a9bb2ed25c32578b5))
+* **ogc:** mirror EDR views in internal OGC mount ([cc5f72c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/cc5f72cfec7318b9200cc2fd9c013a972ee4aa23))
+* **ogc:** ogc A2 replace server metadata placeholders (BDMS-972) ([fd4c446](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fd4c446ba18eb7f87f1103c082ba8fdf3dc00de3))
+* **ogc:** populate the schema view's Values column ([9fc6966](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/9fc69668792cb1eb9534ae40cea874c1b4178355))
+* **ogc:** publish a well water-column layer ([1a9361a](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1a9361a7c0f7b03e5a3729ec4fa0d8dae2b50e03))
+* **ogc:** publish a well water-column layer ([8a0c3ce](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8a0c3cef8e18758f7c019f37f3869c5d6f4b48a6))
+* **ogc:** replace server metadata placeholders ([93206ab](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/93206abee8a68aa4cfeba58669eb48b47e0fadc7))
+* **ogc:** serve field descriptions from /schema ([f6af982](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/f6af982633c31c36977f80ffbf31ca58c1e2cc5c))
+* **scripts:** seed the test DB with real NMA legacy chemistry ([8e55d1d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8e55d1d44d98df7e54df5becc32d29a4bab439c4))
+* **transducer:** add data_maturity to observations ([09926b2](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/09926b239d2e78b8a29b6f3f3198220ac2d15aad))
+* **transducer:** add data_maturity to observations ([9dff7fb](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/9dff7fb5a9282f14a182500d51320e606683ba44))
+* **transducer:** backfill data_maturity on acoustic (Wellntel) observations ([60ffcf4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/60ffcf44efd609a30c91ea02b3b02ee390d7112a))
+* **transducer:** backfill data_maturity on acoustic observations ([7a3915f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7a3915f41a7eaa3da7f6886e0a93d3caa7cbdfe0))
+* **transducer:** publish and range-delete for corrected hydrographs ([cfd9243](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/cfd9243baa1fb46bfe0801802e5773d71b8a7628))
+* **transducer:** publish and range-delete for corrected hydrographs ([63bf502](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/63bf5023e1cd4e538671350b698acbd4107d5562))
+
+
+### Bug Fixes
+
+* **api/asset:** update access from admin to editor ([c73d866](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c73d8668afd46f2c00831780d5eb034e535eb6c5))
+* **build:** package data_migrations with the app ([32c6462](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/32c6462afb05f433863acf96d35ad4147052a673))
+* **ci:** assert the heartbeat run succeeded ([f95ad29](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/f95ad29ddee91318b7076f4271fc1e1f66fd9a29))
+* correct water_wells collection name in README examples and Added time_field(BDMS-973) ([#823](https://github.com/DataIntegrationGroup/OcotilloAPI/issues/823)) ([395a63c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/395a63c95712d241d93960442ff9c94981503a90))
+* **db:** repair EDR water views skipped by a stamped revision ([0cc0d93](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0cc0d93af78814608d0c8c3c2a38f6b9c3b6e308))
+* **db:** repair EDR water views skipped by a stamped revision ([e448741](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e448741fe6a733a72f2e994b239c915dc4318ca0))
+* drop support for Python 3.7-3.9 (<a ([b779661](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b7796616bf7e8a3adac0e425f48f688013d763b2))
+* **edr:** expose thing_type and materialize the chemistry coverages ([aac3d87](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/aac3d8714512c840b6930d640d54e0fbd4e0a586))
+* **edr:** implement pygeoapi's instance contract ([02f0fe5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/02f0fe50f4825c2b46580a2ff9dfe8acb4388d35))
+* **edr:** source water-chemistry EDR from the legacy NMA tables ([ad37f78](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ad37f783e034b92e2fcbd27cba525fded5f5ea85))
+* **edr:** source water-chemistry EDR from the legacy NMA tables ([8863430](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/88634304f44b8a445f60b8e909e356f5c1f73080))
+* expand actively_monitored_wells to include wells from all groups(BDMS-974/1178) ([#866](https://github.com/DataIntegrationGroup/OcotilloAPI/issues/866)) ([550fc18](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/550fc18331be4f7f647a3d0fe87b999cf89fa096))
+* **gis:** document the content type the artifact routes actually send ([70b21b8](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/70b21b893e7e9f3f071e2b37a757018ffd6c8bae))
+* **ingestion:** do not match on external ids by default ([d102cb7](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d102cb7b4b760bdbc4beaa50ee75101995a88931))
+* **ingestion:** do not overwrite approved observations by default ([b522052](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b522052b26c2828bd0424e72e2d69982867a4fc4))
+* **ingestion:** do not overwrite approved observations by default ([5a50381](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/5a50381b243569e64348ab1b5f95db3509ba8e64))
+* **ingestion:** grant bucket read, and name the pipeline after the bucket ([b127f52](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b127f5230dded63db0274fc3ef277646c5cc57b1))
+* **ingestion:** grant bucket read, and name the pipeline after the bucket ([c390073](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c3900738408544a8614f347e11dde3bdf24aba1b))
+* **ingestion:** import ThingIdLink from where it actually lives ([50963f4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/50963f4e42f275dd9aafd4b041a6cc8701aafcec))
+* **ingestion:** install the repository into the Dagster+ image ([619cdf3](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/619cdf3686d4b676ecd41d797cbe4dea68a6f2d0))
+* **ingestion:** install the repository into the Dagster+ image ([7795959](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7795959b6d3fd4f8511f54426329d5fd0ac8b603))
+* **ingestion:** make db and domain importable in the deployed image ([a425954](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a4259545fd47344f4e8262178198e85463c9b0bb))
+* **ingestion:** make the IAM database path internally consistent ([f246d61](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/f246d61107f376a23add2856968eda6c5085c70f))
+* **ingestion:** make the role grants runnable and drop the CREATE ROLE ([0bd2213](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0bd2213a30061da949434ee0b29c8dd15739b15f))
+* **ingestion:** make the role grants runnable and drop the CREATE ROLE ([33b541c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/33b541c866331092a29cf0e3af3bef30d3ce05b3))
+* **ingestion:** raise the ingestion floor to 2024 ([ac7b5e8](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ac7b5e86a1182316bd65ac3e79a420ce0eb972fe))
+* **ingestion:** reject a bare Cloud SQL instance name ([a178f90](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a178f90132cacabbc09ccd4cb77055defc48d839))
+* **ingestion:** repair two CI failures the first PR run exposed ([fd6e869](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fd6e86920a27ef373be7782c8e3c2736fe1ec7c1))
+* **ingestion:** report the import environment from the heartbeat asset ([5ccb0d6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/5ccb0d67602b844bb79182647f20a7c496f7869f))
+* **ingestion:** set code location env vars at a scope that reaches the container ([773de45](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/773de45af40ef94be64e38d2ce496852afdffa6e))
+* **ingestion:** set code location env vars at a scope that reaches the container ([affa35c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/affa35c3e0f5504cf4d85588f73e91a48bc21d50))
+* **ingestion:** set PYTHONPATH and report the import environment ([1df7d8c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1df7d8c63a85ce7a48146fd9ba31159a2c32e06c))
+* **ingestion:** stop duplicate instants reaching one INSERT ([ef38ef2](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ef38ef236149f99b1b7b9a2e2c075a7ff01e9ff7))
+* **ingestion:** stop duplicate instants reaching one INSERT ([1405ce9](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1405ce9c05c75df7c0e4c6cf288249d02c3b58a8))
+* **ingestion:** supply GCP credentials in a runtime that has none ([8c1c32d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8c1c32d8ac2f5ff7d2aaae7e84df9583c5608b05))
+* **ingestion:** supply GCP credentials in a runtime that has none ([2799f91](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2799f9188f20770cc8d14540a0443a70dca11632))
+* **ingestion:** write the raw zone as parquet ([9aa0eac](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/9aa0eac8e1cf6ae5ca9b6d6c9caac78b1efb0851))
+* **ingestion:** write the raw zone as parquet ([78bc921](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/78bc921627fbad2ad546c84aa7a1a375d66cba4e))
+* **ogc:** drop duplicate collections step definition ([3882a5d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3882a5df77138a46e34fd53711284892e01a2408))
+* **ogc:** gate ogc_waterlevels on the well's release status ([c47f481](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c47f4813a7a4e47aad9b149a70631ec727ee8b1c))
+* **ogc:** isolate public and internal pygeoapi module globals ([5bb2aae](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/5bb2aae7cf967a967e88610365df6dfe216b6b0f))
+* **ogc:** isolate public and internal pygeoapi module globals ([860a3f4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/860a3f45ed7ab0ca352b69d006324c8f9fb7a289))
+* **ogc:** re-point migration to new staging head ([8ae9fe1](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8ae9fe1873f3c6e7bbd62ea9e8e08fa76bdae1db))
+* **ogc:** stop EDR field dicts leaking between requests ([598f1ac](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/598f1acc7168743aa742667ec6e5d6769e8434c7))
+* **seed:** load reference data even when migrations seeded a term ([cdb4246](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/cdb4246d1cde5474b44f293988144f6642088794))
+* **seed:** load reference data even when migrations seeded a term ([fb64f68](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fb64f68300631b871dc12b569b1f75214802ce00))
+* **tests:** remove hardcoded group id assumption ([2c17ce6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2c17ce6301f11db4108bb6d977597b281a3af4b6))
+* **thing:** a well with no location no longer 500s the listing ([bff7faa](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/bff7faa91dbc14f5b3f8635082a1d32b9e3a77ed))
+* **transducer:** backfill data_maturity from the legacy QC flag ([95b9b78](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/95b9b78b8dbd74b5ee08859bb48d57ff3192ec75))
+* **transducer:** serialize series writes and scope the publish parameter ([4701979](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4701979f8b4240e1bd1db53e820626476344e8df))
+* **transducer:** spell the block time-order constraint correctly ([ba73c6b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ba73c6b012c9c780cb3dc5c3d0bd67e559882867))
+
## [1.2.0-rc.1](https://github.com/DataIntegrationGroup/OcotilloAPI/compare/v1.2.0-rc...v1.2.0-rc.1) (2026-07-21)
From f4516bc61448f369d1946b7c1ffffc34e6994bbf Mon Sep 17 00:00:00 2001
From: jakeross
Date: Mon, 24 Aug 2026 16:17:01 -0700
Subject: [PATCH 151/151] chore(transfers): drop the Procfile that fed the
transfer build
The repo's Procfile (`web: python3 -m transfers.transfer`) existed only so a
Cloud Build trigger could buildpack-build the deprecated NM_Aquifer transfer
driver into a deployed job. That build has been failing on every staging commit
since 2026-08-21 and posts a red check on every PR, including release
promotions.
The transfer drivers are deprecated and run by hand for backfills; nothing
should build or trigger them automatically. Remove the Procfile and say so in
transfers/README.md.
The Cloud Build trigger itself lives in GCP (nma-ocotillo-transfer,
us-central1) and is deleted separately.
Co-Authored-By: Claude Opus 5
---
Procfile | 1 -
transfers/README.md | 9 +++++++++
2 files changed, 9 insertions(+), 1 deletion(-)
delete mode 100644 Procfile
diff --git a/Procfile b/Procfile
deleted file mode 100644
index 2486669cb..000000000
--- a/Procfile
+++ /dev/null
@@ -1 +0,0 @@
-web: python3 -m transfers.transfer
diff --git a/transfers/README.md b/transfers/README.md
index 2bac5b0b1..e35c0ac62 100644
--- a/transfers/README.md
+++ b/transfers/README.md
@@ -16,6 +16,15 @@ because the tables they populate (`NMA_*`, `NMW_*`) are still read by live API
routes, so backfills and re-runs must remain possible -- but they receive no new
features.
+## No automated build or trigger
+
+There is no deployed transfer job any more. The repo used to carry a `Procfile`
+(`web: python3 -m transfers.transfer`) that a Cloud Build trigger
+(`nma-ocotillo-transfer`, us-central1) built with buildpacks on every push; that
+trigger and the `Procfile` are gone. Nothing runs a transfer on its own -- the
+drivers are invoked by hand, by an engineer, for a backfill. Do not add a
+`Procfile` or a build trigger back.
+
Consequently their tests live in `tests/transfers/` and do **not** gate CI
(`.github/workflows/tests.yml` runs pytest with `--ignore=tests/transfers`), and
`transfers/*` is omitted from the coverage total in `pyproject.toml`. Run them by