From bf596bdd15b5856f07291437faddfca4990b74bd Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 6 Jun 2026 16:20:32 -0600 Subject: [PATCH 001/160] feat(transfers): NM_Wells 1:1 staging mirror + ref-table lexicon loader Phase 1 of the NM_Wells -> Ocotillo migration: faithful column-for-column staging mirror of the legacy NM_Wells SQL Server DB, plus loaders. The transform into the Ocotillo model (Phase 2) is documented inline but not built. - db/nmw_legacy.py: 17 NMW_* mirror models (5 Main, 7 Geothermal, 5 DST), source column names preserved, per-column Phase-2 transform-target notes. Main columns from the planning workbook field map; Geothermal/DST columns, lengths and PKs taken directly from the SQL-dump DDL. - alembic: two migrations (Main; Geothermal+DST) chained off current head, bodies generated from model metadata. Single head. - transfers/nmw_mirror_transfer.py: data-driven CSV -> NMW_* loader with type coercion (NaN/NaT -> None, rowversion dropped), chunked ON CONFLICT upsert. Gated by TRANSFER_NMW_MIRROR (default off; separate source DB). - transfers/reference_lexicon_transfer.py: loads all 49 ref_* lookups into the lexicon (category per table), idempotent like init_lexicon; registered as a foundational transfer. - db/__init__.py, transfers/transfer.py, .env.example: wiring. Co-Authored-By: Claude Opus 4.8 --- .env.example | 2 + ...x0y1z2_nmw_legacy_staging_mirror_tables.py | 211 ++++++ ...y1z2a3_nmw_geothermal_dst_mirror_tables.py | 341 +++++++++ db/__init__.py | 1 + db/nmw_legacy.py | 647 ++++++++++++++++++ transfers/nmw_mirror_transfer.py | 250 +++++++ transfers/reference_lexicon_transfer.py | 362 ++++++++++ transfers/transfer.py | 12 +- 8 files changed, 1825 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/u7v8w9x0y1z2_nmw_legacy_staging_mirror_tables.py create mode 100644 alembic/versions/v8w9x0y1z2a3_nmw_geothermal_dst_mirror_tables.py create mode 100644 db/nmw_legacy.py create mode 100644 transfers/nmw_mirror_transfer.py create mode 100644 transfers/reference_lexicon_transfer.py diff --git a/.env.example b/.env.example index 3f835882e..2c4534696 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,8 @@ TRANSFER_NGWMN_VIEWS=True TRANSFER_WATERLEVELS_PRESSURE_DAILY=True TRANSFER_WEATHER_DATA=True TRANSFER_MINOR_TRACE_CHEMISTRY=True +# NM_Wells 1:1 staging mirror load (separate source DB; off by default) +TRANSFER_NMW_MIRROR=False # asset storage GCS_BUCKET_NAME= diff --git a/alembic/versions/u7v8w9x0y1z2_nmw_legacy_staging_mirror_tables.py b/alembic/versions/u7v8w9x0y1z2_nmw_legacy_staging_mirror_tables.py new file mode 100644 index 000000000..7fb64962b --- /dev/null +++ b/alembic/versions/u7v8w9x0y1z2_nmw_legacy_staging_mirror_tables.py @@ -0,0 +1,211 @@ +"""NM_Wells 1:1 staging mirror tables + +Revision ID: u7v8w9x0y1z2 +Revises: t6u7v8w9x0y1 +Create Date: 2026-06-06 00:00:00.000000 + +1:1 staging mirror of the legacy NM_Wells SQL Server "Migrate First / Main" +tables (see db/nmw_legacy.py and docs/nm_wells-migration.md). Faithful, +column-for-column copies; the transform into the Ocotillo data model is a +later phase. + + tbl_well_locations -> NMW_WellLocations + tbl_well_headers -> NMW_WellHeaders + tbl_well_records -> NMW_WellRecords + tbl_well_z_datum -> NMW_WellZDatum + tbl_well_samples -> NMW_WellSamples +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "u7v8w9x0y1z2" +down_revision: Union[str, Sequence[str], None] = "t6u7v8w9x0y1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "NMW_WellLocations", + sa.Column("OBJECTID", sa.Integer(), nullable=False), + sa.Column("WellDataID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("Well_ID", sa.String(), nullable=True), + sa.Column("Import_ID", sa.Integer(), nullable=True), + sa.Column("Township", sa.Float(), nullable=True), + sa.Column("NorS_TDir", sa.String(), nullable=True), + sa.Column("Range", sa.Float(), nullable=True), + sa.Column("EorW_RDir", sa.String(), nullable=True), + sa.Column("Sectn", sa.SmallInteger(), nullable=True), + sa.Column("SectnPart", sa.String(), nullable=True), + sa.Column("UnitLetter", sa.String(), nullable=True), + sa.Column("UTM_zone", sa.String(), nullable=True), + sa.Column("State", sa.String(), nullable=True), + sa.Column("County", sa.String(), nullable=True), + sa.Column("Basin", sa.String(), nullable=True), + sa.Column("Footage_NS", sa.Float(), nullable=True), + sa.Column("NorS_FDir", sa.String(), nullable=True), + sa.Column("Footage_EW", sa.Float(), nullable=True), + sa.Column("EorW_FDir", sa.String(), nullable=True), + sa.Column("Lat_min", sa.SmallInteger(), nullable=True), + sa.Column("Lat_sec", sa.Float(), nullable=True), + sa.Column("Long_deg", sa.SmallInteger(), nullable=True), + sa.Column("Long_min", sa.SmallInteger(), nullable=True), + sa.Column("Long_sec", sa.Float(), nullable=True), + sa.Column("Lat_dd27", sa.Float(), nullable=True), + sa.Column("Long_dd27", sa.Float(), nullable=True), + sa.Column("Lat_dd83", sa.Float(), nullable=True), + sa.Column("Long_dd83", sa.Float(), nullable=True), + sa.Column("SourceID", sa.String(), nullable=True), + sa.Column("SourceDatum", sa.String(), nullable=True), + sa.Column("SourceUnits", sa.String(), nullable=True), + sa.Column("LocAccType", sa.String(), nullable=True), + sa.Column("LocAccMeas", sa.String(), nullable=True), + sa.Column("LocAccVal", sa.Float(), nullable=True), + sa.Column("Duplicated", sa.SmallInteger(), nullable=True), + sa.Column("Exclude", sa.SmallInteger(), nullable=True), + sa.Column("Comments", sa.String(), nullable=True), + sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.Column("API", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("OBJECTID"), + ) + op.create_index( + "ix_NMW_WellLocations_WellDataID", "NMW_WellLocations", ["WellDataID"] + ) + + op.create_table( + "NMW_WellHeaders", + sa.Column("OBJECTID", sa.Integer(), nullable=True), + sa.Column("WellDataID", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("WellSpotID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("API", sa.String(), nullable=True), + sa.Column("WellClass", sa.String(), nullable=True), + sa.Column("WellType", sa.String(), nullable=True), + sa.Column("WellOrient", sa.String(), nullable=True), + sa.Column("CurWellNam", sa.String(), nullable=True), + sa.Column("CurWellNum", sa.String(), nullable=True), + sa.Column("CurStatus", sa.String(), nullable=True), + sa.Column("PrdPoolCnt", sa.SmallInteger(), nullable=True), + sa.Column("CurOperatr", sa.String(), nullable=True), + sa.Column("CurOwner", sa.String(), nullable=True), + sa.Column("TotalDepth", sa.Float(), nullable=True), + sa.Column("Well_TVD", sa.Float(), nullable=True), + sa.Column("Fm_TD", sa.String(), nullable=True), + sa.Column("Age_TD", sa.String(), nullable=True), + sa.Column("SpudDate", sa.DateTime(), nullable=True), + sa.Column("ComplDate", sa.DateTime(), nullable=True), + sa.Column("PlugDate", sa.DateTime(), nullable=True), + sa.Column("PlugBack", sa.Float(), nullable=True), + sa.Column("BridgePlug", sa.String(), nullable=True), + sa.Column("ScoutTickt", sa.SmallInteger(), nullable=True), + sa.Column("DwnHoleSur", sa.SmallInteger(), nullable=True), + sa.Column("GeolLog", sa.SmallInteger(), nullable=True), + sa.Column("Geophyslog", sa.SmallInteger(), nullable=True), + sa.Column("GthrmExist", sa.SmallInteger(), nullable=True), + sa.Column("PetroData", sa.SmallInteger(), nullable=True), + sa.Column("CoreExists", sa.SmallInteger(), nullable=True), + sa.Column("Cuttings", sa.SmallInteger(), nullable=True), + sa.Column("SampleData", sa.SmallInteger(), nullable=True), + sa.Column("Comments", sa.String(), nullable=True), + sa.Column("Import_ID", sa.String(), nullable=True), + sa.Column("Import_DB", sa.String(), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("WellDataID"), + ) + + op.create_table( + "NMW_WellRecords", + sa.Column("OBJECTID", sa.Integer(), nullable=True), + sa.Column("RecrdSetID", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("WellDataID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("RecrdClass", sa.String(), nullable=True), + sa.Column("SourceID", sa.String(), nullable=True), + sa.Column("ActionDate", sa.DateTime(), nullable=True), + sa.Column("WellName", sa.String(), nullable=True), + sa.Column("WellNumber", sa.String(), nullable=True), + sa.Column("API_suffix", sa.String(), nullable=True), + sa.Column("EnteredBy", sa.String(), nullable=True), + sa.Column("EntryDate", sa.DateTime(), nullable=True), + sa.Column("Comments", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("RecrdSetID"), + ) + op.create_index("ix_NMW_WellRecords_WellDataID", "NMW_WellRecords", ["WellDataID"]) + + op.create_table( + "NMW_WellZDatum", + sa.Column("OBJECTID", sa.Integer(), nullable=True), + sa.Column("RecrdsetID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("Elev_GL", sa.Float(), nullable=True), + sa.Column("Elev_DF", sa.Float(), nullable=True), + sa.Column("Elev_KB", sa.Float(), nullable=True), + sa.Column("Elev_unspc", sa.Float(), nullable=True), + sa.Column("DatumElev", sa.Float(), nullable=True), + sa.Column("DepthDatum", sa.String(), nullable=True), + sa.Column("DepthUnits", sa.String(), nullable=True), + sa.Column("Z_datum", sa.String(), nullable=True), + sa.Column("Z_units", sa.String(), nullable=True), + sa.Column("ElevSource", sa.String(), nullable=True), + sa.Column("ElvAccType", sa.String(), nullable=True), + sa.Column("ElvAccMeas", sa.String(), nullable=True), + sa.Column("ElvAccVal", sa.Float(), nullable=True), + sa.Column("Comments", sa.String(), nullable=True), + sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("GlobalID"), + ) + op.create_index("ix_NMW_WellZDatum_RecrdsetID", "NMW_WellZDatum", ["RecrdsetID"]) + + op.create_table( + "NMW_WellSamples", + sa.Column("OBJECTID", sa.Integer(), nullable=True), + sa.Column("SamplSetID", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("RecrdsetID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("SmpSetName", sa.String(), nullable=True), + sa.Column("SamplClass", sa.String(), nullable=True), + sa.Column("SampleType", sa.String(), nullable=True), + sa.Column("SampleFm", sa.String(), nullable=True), + sa.Column("SampleLoc", sa.String(), nullable=True), + sa.Column("SampleDate", sa.DateTime(), nullable=True), + sa.Column("From_Depth", sa.Float(), nullable=True), + sa.Column("To_Depth", sa.Float(), nullable=True), + sa.Column("SmpDpUnt", sa.String(), nullable=True), + sa.Column("From_TVD", sa.Float(), nullable=True), + sa.Column("To_TVD", sa.Float(), nullable=True), + sa.Column("From_Elev", sa.Float(), nullable=True), + sa.Column("To_Elev", sa.Float(), nullable=True), + sa.Column("Porosity", sa.SmallInteger(), nullable=True), + sa.Column("Permeablty", sa.SmallInteger(), nullable=True), + sa.Column("Density", sa.SmallInteger(), nullable=True), + sa.Column("DST_Tests", sa.SmallInteger(), nullable=True), + sa.Column("ThinSect", sa.SmallInteger(), nullable=True), + sa.Column("Geochron", sa.SmallInteger(), nullable=True), + sa.Column("Geochem", sa.SmallInteger(), nullable=True), + sa.Column("Geothermal", sa.SmallInteger(), nullable=True), + sa.Column("WholeRock", sa.SmallInteger(), nullable=True), + sa.Column("Paleontlgy", sa.SmallInteger(), nullable=True), + sa.Column("EnteredBy", sa.String(), nullable=True), + sa.Column("EntryDate", sa.DateTime(), nullable=True), + sa.Column("Notes", sa.String(), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("SamplSetID"), + ) + op.create_index("ix_NMW_WellSamples_RecrdsetID", "NMW_WellSamples", ["RecrdsetID"]) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_NMW_WellSamples_RecrdsetID", table_name="NMW_WellSamples") + op.drop_table("NMW_WellSamples") + op.drop_index("ix_NMW_WellZDatum_RecrdsetID", table_name="NMW_WellZDatum") + op.drop_table("NMW_WellZDatum") + op.drop_index("ix_NMW_WellRecords_WellDataID", table_name="NMW_WellRecords") + op.drop_table("NMW_WellRecords") + op.drop_table("NMW_WellHeaders") + op.drop_index("ix_NMW_WellLocations_WellDataID", table_name="NMW_WellLocations") + op.drop_table("NMW_WellLocations") diff --git a/alembic/versions/v8w9x0y1z2a3_nmw_geothermal_dst_mirror_tables.py b/alembic/versions/v8w9x0y1z2a3_nmw_geothermal_dst_mirror_tables.py new file mode 100644 index 000000000..d88cd2d3b --- /dev/null +++ b/alembic/versions/v8w9x0y1z2a3_nmw_geothermal_dst_mirror_tables.py @@ -0,0 +1,341 @@ +"""NM_Wells geothermal + drill-stem-test 1:1 staging mirror tables + +Revision ID: v8w9x0y1z2a3 +Revises: u7v8w9x0y1z2 +Create Date: 2026-06-06 00:00:01.000000 + +1:1 staging mirror of the NM_Wells "Migrate First" Geothermal and Drill Stem +Test tables (see db/nmw_legacy.py and docs/nm_wells-migration.md). Columns and +lengths taken directly from the NM_Wells SQL dump DDL. + + Geothermal: + tbl_gt_bht_headers -> NMW_GtBhtHeaders + tbl_gt_bht_data -> NMW_GtBhtData + tbl_ws_intervals -> NMW_WsIntervals + tbl_gt_conductivity -> NMW_GtConductivity + tbl_gt_heat_flow -> NMW_GtHeatFlow + tbl_gt_sum_heat_flow -> NMW_GtSumHeatFlow + tbl_gt_temp_depths -> NMW_GtTempDepths + Drill Stem Tests: + tbl_ws_dst_headers -> NMW_WsDstHeaders + tbl_ws_dst_intervals -> NMW_WsDstIntervals + tbl_ws_dst_flow_history-> NMW_WsDstFlowHistory + tbl_ws_dst_fluid_properties -> NMW_WsDstFluidProperties + tbl_ws_dst_pressure -> NMW_WsDstPressure +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "v8w9x0y1z2a3" +down_revision: Union[str, Sequence[str], None] = "u7v8w9x0y1z2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "NMW_GtBhtHeaders", + sa.Column("OBJECTID", sa.Integer(), nullable=True), + sa.Column("BHTGUID", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("SamplSetID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("BoreDia", sa.Float(), nullable=True), + sa.Column("BoreUnits", sa.String(length=16), nullable=True), + sa.Column("DrillFluid", sa.String(length=16), nullable=True), + sa.Column("TempUnit", sa.String(length=1), nullable=True), + sa.Column("FldSalinity", sa.Float(), nullable=True), + sa.Column("FldRstvity", sa.Float(), nullable=True), + sa.Column("Fluid_pH", sa.Float(), nullable=True), + sa.Column("FldDensity", sa.Float(), nullable=True), + sa.Column("FldLevel", sa.Float(), nullable=True), + sa.Column("FldViscsty", sa.Float(), nullable=True), + sa.Column("FluidLoss", sa.String(length=50), nullable=True), + sa.Column("Notes", sa.String(length=255), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("BHTGUID"), + ) + op.create_index( + "ix_NMW_GtBhtHeaders_SamplSetID", "NMW_GtBhtHeaders", ["SamplSetID"] + ) + + op.create_table( + "NMW_GtBhtData", + sa.Column("OBJECTID", sa.Integer(), nullable=False), + sa.Column("BHTGUID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("Depth", sa.Float(), nullable=True), + sa.Column("BHT", sa.Float(), nullable=True), + sa.Column("TempUnit", sa.String(length=5), nullable=True), + sa.Column("HrsSnceCir", sa.Float(), nullable=True), + sa.Column("DateMeasrd", sa.DateTime(), nullable=True), + sa.Column("Comments", sa.String(length=255), nullable=True), + sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("OBJECTID"), + ) + op.create_index("ix_NMW_GtBhtData_BHTGUID", "NMW_GtBhtData", ["BHTGUID"]) + + op.create_table( + "NMW_WsIntervals", + sa.Column("OBJECTID", sa.Integer(), nullable=True), + sa.Column("IntrvlGUID", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("SamplSetID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("SampleID", sa.String(length=128), nullable=True), + sa.Column("From_Depth", sa.Float(), nullable=True), + sa.Column("To_Depth", sa.Float(), nullable=True), + sa.Column("From_TVD", sa.Float(), nullable=True), + sa.Column("To_TVD", sa.Float(), nullable=True), + sa.Column("From_Elev", sa.Float(), nullable=True), + sa.Column("To_Elev", sa.Float(), nullable=True), + sa.Column("Intv_Notes", sa.String(length=255), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("IntrvlGUID"), + ) + op.create_index("ix_NMW_WsIntervals_SamplSetID", "NMW_WsIntervals", ["SamplSetID"]) + + op.create_table( + "NMW_GtConductivity", + sa.Column("OBJECTID", sa.Integer(), nullable=False), + sa.Column("IntrvlGUID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("Cnductvity", sa.Float(), nullable=True), + sa.Column("CnductUnit", sa.String(length=3), nullable=True), + sa.Column("Comments", sa.String(length=255), nullable=True), + sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("OBJECTID"), + ) + op.create_index( + "ix_NMW_GtConductivity_IntrvlGUID", "NMW_GtConductivity", ["IntrvlGUID"] + ) + + op.create_table( + "NMW_GtHeatFlow", + sa.Column("OBJECTID", sa.Integer(), nullable=False), + sa.Column("IntrvlGUID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("Gradient", sa.Float(), nullable=True), + sa.Column("Ka", sa.Float(), nullable=True), + sa.Column("Ka_unit", sa.String(length=3), nullable=True), + sa.Column("Pm", sa.Float(), nullable=True), + sa.Column("Kpr", sa.Float(), nullable=True), + sa.Column("Kpr_unit", sa.String(length=3), nullable=True), + sa.Column("Q", sa.Float(), nullable=True), + sa.Column("Q_unit", sa.String(length=3), nullable=True), + sa.Column("Comments", sa.String(length=255), nullable=True), + sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("OBJECTID"), + ) + op.create_index("ix_NMW_GtHeatFlow_IntrvlGUID", "NMW_GtHeatFlow", ["IntrvlGUID"]) + + op.create_table( + "NMW_GtSumHeatFlow", + sa.Column("OBJECTID", sa.Integer(), nullable=False), + sa.Column("RecrdSetID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("SamplSetID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("LithClass", sa.String(length=50), nullable=True), + sa.Column("UnitBasis", sa.String(length=16), nullable=True), + sa.Column("UnitName", sa.String(length=128), nullable=True), + sa.Column("GeoID", sa.String(length=16), nullable=True), + sa.Column("FromDepth", sa.Float(), nullable=True), + sa.Column("ToDepth", sa.Float(), nullable=True), + sa.Column("DepthUnit", sa.String(length=8), nullable=True), + sa.Column("From_Elev", sa.Float(), nullable=True), + sa.Column("To_Elev", sa.Float(), nullable=True), + sa.Column("ThermlGrad", sa.Float(), nullable=True), + sa.Column("TGError", sa.Float(), nullable=True), + sa.Column("GradUnit", sa.String(length=3), nullable=True), + sa.Column("TGradRange", sa.String(length=15), nullable=True), + sa.Column("SampleType", sa.String(length=50), nullable=True), + sa.Column("NumSamples", sa.SmallInteger(), nullable=True), + sa.Column("ThermlCond", sa.Float(), nullable=True), + sa.Column("TCondError", sa.Float(), nullable=True), + sa.Column("TCondUnit", sa.String(length=3), nullable=True), + sa.Column("TCondRange", sa.String(length=15), nullable=True), + sa.Column("HeatFlow", sa.Float(), nullable=True), + sa.Column("HtFlowErr", sa.Float(), nullable=True), + sa.Column("HtFlowUnit", sa.String(length=3), nullable=True), + sa.Column("HtFlowEst", sa.Float(), nullable=True), + sa.Column("Quality", sa.String(length=50), nullable=True), + sa.Column("Comments", sa.String(length=255), nullable=True), + sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("OBJECTID"), + ) + op.create_index( + "ix_NMW_GtSumHeatFlow_RecrdSetID", "NMW_GtSumHeatFlow", ["RecrdSetID"] + ) + op.create_index( + "ix_NMW_GtSumHeatFlow_SamplSetID", "NMW_GtSumHeatFlow", ["SamplSetID"] + ) + + op.create_table( + "NMW_GtTempDepths", + sa.Column("OBJECTID", sa.Integer(), nullable=False), + sa.Column("SamplSetID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("Depth", sa.Float(), nullable=True), + sa.Column("Temp", sa.Float(), nullable=True), + sa.Column("TempUnit", sa.String(length=1), nullable=True), + sa.Column("IntrvlGrad", sa.Float(), nullable=True), + sa.Column("Comments", sa.String(length=255), nullable=True), + sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("OBJECTID"), + ) + op.create_index( + "ix_NMW_GtTempDepths_SamplSetID", "NMW_GtTempDepths", ["SamplSetID"] + ) + + op.create_table( + "NMW_WsDstHeaders", + sa.Column("OBJECTID", sa.Integer(), nullable=True), + sa.Column("DSTGUID", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("SamplSetID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("TestType", sa.String(length=50), nullable=True), + sa.Column("DSTOprator", sa.String(length=50), nullable=True), + sa.Column("PressUnits", sa.String(length=8), nullable=True), + sa.Column("TempUnit", sa.String(length=1), nullable=True), + sa.Column("PipeDiaUnt", sa.String(length=8), nullable=True), + sa.Column("PipeLenUnt", sa.String(length=8), nullable=True), + sa.Column("ChokeSizUn", sa.String(length=8), nullable=True), + sa.Column("Notes", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint("DSTGUID"), + ) + op.create_index( + "ix_NMW_WsDstHeaders_SamplSetID", "NMW_WsDstHeaders", ["SamplSetID"] + ) + + op.create_table( + "NMW_WsDstIntervals", + sa.Column("OBJECTID", sa.Integer(), nullable=True), + sa.Column("DSTInterval", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("DSTGUID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("DSTName", sa.String(length=128), nullable=True), + sa.Column("TargetFm", sa.String(length=16), nullable=True), + sa.Column("DSTDate", sa.DateTime(), nullable=True), + sa.Column("DSTNumber", sa.SmallInteger(), nullable=True), + sa.Column("Status", sa.String(length=255), nullable=True), + sa.Column("StatusDate", sa.DateTime(), nullable=True), + sa.Column("PackrFrom", sa.Float(), nullable=True), + sa.Column("PackerTo", sa.Float(), nullable=True), + sa.Column("SrfChokeSz", sa.Float(), nullable=True), + sa.Column("BotChokeSz", sa.Float(), nullable=True), + sa.Column("PipeDia", sa.Float(), nullable=True), + sa.Column("PipeLength", sa.Float(), nullable=True), + sa.Column("Notes", sa.String(length=255), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("DSTInterval"), + ) + op.create_index("ix_NMW_WsDstIntervals_DSTGUID", "NMW_WsDstIntervals", ["DSTGUID"]) + + op.create_table( + "NMW_WsDstFlowHistory", + sa.Column("OBJECTID", sa.Integer(), nullable=False), + sa.Column("DSTInterval", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("Operation", sa.String(length=255), nullable=True), + sa.Column("StartTime", sa.DateTime(), nullable=True), + sa.Column("EndTime", sa.DateTime(), nullable=True), + sa.Column("Duration", sa.Float(), nullable=True), + sa.Column("Pressure", sa.Float(), nullable=True), + sa.Column("Temp", sa.Float(), nullable=True), + sa.Column("RecovColmn", sa.Float(), nullable=True), + sa.Column("RecovType", sa.String(length=255), nullable=True), + sa.Column("Notes", sa.String(length=255), nullable=True), + sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("OBJECTID"), + ) + op.create_index( + "ix_NMW_WsDstFlowHistory_DSTInterval", "NMW_WsDstFlowHistory", ["DSTInterval"] + ) + + op.create_table( + "NMW_WsDstFluidProperties", + sa.Column("OBJECTID", sa.Integer(), nullable=False), + sa.Column("DSTInterval", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("SourceLoc", sa.String(length=255), nullable=True), + sa.Column("Resistivty", sa.Float(), nullable=True), + sa.Column("Temp", sa.Float(), nullable=True), + sa.Column("Chlorides", sa.Float(), nullable=True), + sa.Column("Notes", sa.String(length=255), nullable=True), + sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("OBJECTID"), + ) + op.create_index( + "ix_NMW_WsDstFluidProperties_DSTInterval", + "NMW_WsDstFluidProperties", + ["DSTInterval"], + ) + + op.create_table( + "NMW_WsDstPressure", + sa.Column("OBJECTID", sa.Integer(), nullable=False), + sa.Column("DSTInterval", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("PrsGageDpt", sa.Float(), nullable=True), + sa.Column("BlankedOff", sa.SmallInteger(), nullable=True), + sa.Column("InShtInMin", sa.Float(), nullable=True), + sa.Column("FlwPrsInMin", sa.Float(), nullable=True), + sa.Column("PrsInShtIn", sa.Float(), nullable=True), + sa.Column("PrsInitClsdIn", sa.Float(), nullable=True), + sa.Column("FnShtInMin", sa.Float(), nullable=True), + sa.Column("FlwPrsFinMin", sa.Float(), nullable=True), + sa.Column("PrsFnShtIn", sa.Float(), nullable=True), + sa.Column("ShtInPrMth", sa.String(length=255), nullable=True), + sa.Column("HydrostPrsIn", sa.Float(), nullable=True), + sa.Column("HydStPrsFl", sa.Float(), nullable=True), + sa.Column("HydstPrMth", sa.String(length=255), nullable=True), + sa.Column("EquilPress", sa.Float(), nullable=True), + sa.Column("EqlPrsMth", sa.String(length=255), nullable=True), + sa.Column("FlowPrsMin", sa.Float(), nullable=True), + sa.Column("FlowPrsMax", sa.Float(), nullable=True), + sa.Column("FlowPrsMth", sa.String(length=255), nullable=True), + sa.Column("DSTFluid", sa.String(length=128), nullable=True), + sa.Column("FmTemp", sa.Float(), nullable=True), + sa.Column("TempCorrtn", sa.Float(), nullable=True), + sa.Column("TempFlowng", sa.Float(), nullable=True), + sa.Column("TempUnit", sa.String(length=5), nullable=True), + sa.Column("Notes", sa.String(length=255), nullable=True), + sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), + sa.PrimaryKeyConstraint("OBJECTID"), + ) + op.create_index( + "ix_NMW_WsDstPressure_DSTInterval", "NMW_WsDstPressure", ["DSTInterval"] + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_NMW_WsDstPressure_DSTInterval", table_name="NMW_WsDstPressure") + op.drop_table("NMW_WsDstPressure") + op.drop_index( + "ix_NMW_WsDstFluidProperties_DSTInterval", table_name="NMW_WsDstFluidProperties" + ) + op.drop_table("NMW_WsDstFluidProperties") + op.drop_index( + "ix_NMW_WsDstFlowHistory_DSTInterval", table_name="NMW_WsDstFlowHistory" + ) + op.drop_table("NMW_WsDstFlowHistory") + op.drop_index("ix_NMW_WsDstIntervals_DSTGUID", table_name="NMW_WsDstIntervals") + op.drop_table("NMW_WsDstIntervals") + op.drop_index("ix_NMW_WsDstHeaders_SamplSetID", table_name="NMW_WsDstHeaders") + op.drop_table("NMW_WsDstHeaders") + op.drop_index("ix_NMW_GtTempDepths_SamplSetID", table_name="NMW_GtTempDepths") + op.drop_table("NMW_GtTempDepths") + op.drop_index("ix_NMW_GtSumHeatFlow_RecrdSetID", table_name="NMW_GtSumHeatFlow") + op.drop_index("ix_NMW_GtSumHeatFlow_SamplSetID", table_name="NMW_GtSumHeatFlow") + op.drop_table("NMW_GtSumHeatFlow") + op.drop_index("ix_NMW_GtHeatFlow_IntrvlGUID", table_name="NMW_GtHeatFlow") + op.drop_table("NMW_GtHeatFlow") + op.drop_index("ix_NMW_GtConductivity_IntrvlGUID", table_name="NMW_GtConductivity") + op.drop_table("NMW_GtConductivity") + op.drop_index("ix_NMW_WsIntervals_SamplSetID", table_name="NMW_WsIntervals") + op.drop_table("NMW_WsIntervals") + op.drop_index("ix_NMW_GtBhtData_BHTGUID", table_name="NMW_GtBhtData") + op.drop_table("NMW_GtBhtData") + op.drop_index("ix_NMW_GtBhtHeaders_SamplSetID", table_name="NMW_GtBhtHeaders") + op.drop_table("NMW_GtBhtHeaders") diff --git a/db/__init__.py b/db/__init__.py index a376381b1..4e2e7fb3a 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -59,6 +59,7 @@ from db.thing_geologic_formation_association import * from db.aquifer_type import * from db.nma_legacy import * +from db.nmw_legacy import * from db.transducer import * from sqlalchemy import ( diff --git a/db/nmw_legacy.py b/db/nmw_legacy.py new file mode 100644 index 000000000..5186fa66d --- /dev/null +++ b/db/nmw_legacy.py @@ -0,0 +1,647 @@ +# =============================================================================== +# 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. +# =============================================================================== +"""1:1 staging mirror of the legacy NM_Wells SQL Server database. + +PURPOSE +------- +These models are a FAITHFUL, column-for-column copy of the NM_Wells source +tables. They are a *staging layer*: data lands here unchanged from the SQL +dump, then a later transform phase maps it into the Ocotillo data model +(Location / Thing / FieldEvent / FieldActivity / Sample / Observation, plus +status_history, measuring_point_history, contact, publication, etc.). + +This file mirrors the convention of ``db/nma_legacy.py`` (the NM_Aquifer +mirror): ``NMW_`` table prefix, original source column names preserved via the +first positional arg to ``mapped_column``, snake_case Python attributes. + +SOURCE +------ +NM_Wells is delivered as a SQL dump. Physical source table names are +``tbl_well_*`` (snake_case). To feed the existing CSV->Pandas->ORM transfer +pipeline, export each source table to CSV (same flow as ``nma_csv_cache``). + +SCOPE (this commit) +------------------- +Mirrors the five "Migrate First / Main" tables that have an authoritative +field-level mapping in the planning workbook +("NM_Wells + Subsurface library.xlsx", sheet 3): + + tbl_well_locations -> NMW_WellLocations + tbl_well_headers -> NMW_WellHeaders + tbl_well_records -> NMW_WellRecords + tbl_well_z_datum -> NMW_WellZDatum + tbl_well_samples -> NMW_WellSamples + +Also mirrors the Geothermal and Drill Stem Test "Migrate First" tables +(columns + lengths taken directly from the NM_Wells SQL dump DDL, so these are +more precise than the five Main tables above whose lengths the sheet omitted): + + Geothermal: + tbl_gt_bht_headers -> NMW_GtBhtHeaders tbl_gt_bht_data -> NMW_GtBhtData + tbl_gt_conductivity -> NMW_GtConductivity tbl_gt_heat_flow -> NMW_GtHeatFlow + tbl_gt_sum_heat_flow-> NMW_GtSumHeatFlow tbl_gt_temp_depths -> NMW_GtTempDepths + tbl_ws_intervals -> NMW_WsIntervals + Drill Stem Tests: + tbl_ws_dst_headers -> NMW_WsDstHeaders tbl_ws_dst_intervals -> NMW_WsDstIntervals + tbl_ws_dst_flow_history -> NMW_WsDstFlowHistory + tbl_ws_dst_fluid_properties -> NMW_WsDstFluidProperties + tbl_ws_dst_pressure -> NMW_WsDstPressure + +Geothermal/DST relationship chains (kept as plain indexed GUID columns, NOT +enforced FKs, since this is staging): + well_samples.SamplSetID <- gt_bht_headers / gt_temp_depths / gt_sum_heat_flow + / ws_intervals / ws_dst_headers (SamplSetID) + gt_bht_headers.BHTGUID <- gt_bht_data.BHTGUID + ws_intervals.IntrvlGUID <- gt_conductivity / gt_heat_flow (IntrvlGUID) + ws_dst_headers.DSTGUID <- ws_dst_intervals.DSTGUID + ws_dst_intervals.DSTInterval <- ws_dst_flow_history / ws_dst_fluid_properties + / ws_dst_pressure (DSTInterval) + well_records.RecrdSetID <- gt_sum_heat_flow.RecrdSetID + +The transform of geothermal/DST into the Ocotillo model is not yet designed +(no field-level mapping in the workbook); see docs/nm_wells-migration.md. + +TRANSFORM NOTES +--------------- +Each column carries an inline note describing its eventual Ocotillo target +(from the mapping sheet). "Drop" = not carried into the Ocotillo model (kept +here only for staging fidelity / audit). See docs/nm_wells-migration.md for +the full plan and the cross-table relationship re-routing +(legacy RecrdSetID -> field_event). + +TYPE MAPPING (SQL Server -> SQLAlchemy) +--------------------------------------- + uniqueidentifier -> postgresql UUID(as_uuid=True) + int -> Integer + smallint -> SmallInteger + real / float -> Float + nvarchar -> String (source lengths not in the sheet; widened) + datetime2 -> DateTime + timestamp -> LargeBinary (SQL Server rowversion; staging only) + +TODO(verify): primary keys below are inferred from the mapping sheet / +relationship notes, not from source DDL. Confirm against the dump. +""" + +from sqlalchemy import ( + DateTime, + Float, + Integer, + LargeBinary, + SmallInteger, + String, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import mapped_column + +from db.base import Base + + +class NMW_WellLocations(Base): + """1:1 mirror of NM_Wells ``tbl_well_locations`` (Main / Migrate First). + + Transform target: ``location`` (point from Lat/Long_dd83, state, county) + plus a new ``NMW_Location`` table for the legacy PLSS/UTM attributes. + """ + + __tablename__ = "NMW_WellLocations" + + # TODO(verify PK): tbl has no clear GUID PK; OBJECTID is the identity col. + object_id = mapped_column("OBJECTID", Integer, primary_key=True) # Drop + well_data_id = mapped_column( + "WellDataID", UUID(as_uuid=True), index=True + ) # -> NMW_Location.well_id (relates header/location/records) + well_id_legacy = mapped_column("Well_ID", String) # Drop + import_id = mapped_column("Import_ID", Integer) # Drop + township = mapped_column("Township", Float) # -> NMW_Location.township + nors_tdir = mapped_column("NorS_TDir", String) # -> NMW_Location.township_n_s + range_ = mapped_column("Range", Float) # -> NMW_Location.range + eorw_rdir = mapped_column("EorW_RDir", String) # -> NMW_Location.range_e_w + sectn = mapped_column("Sectn", SmallInteger) # -> NMW_Location.section + sectn_part = mapped_column("SectnPart", String) # -> NMW_Location.section_portion + unit_letter = mapped_column("UnitLetter", String) # -> NMW_Location.unit_letter + utm_zone = mapped_column("UTM_zone", String) # -> NMW_Location.utm_zone + state = mapped_column("State", String) # -> location.state + county = mapped_column("County", String) # -> location.county + basin = mapped_column("Basin", String) # -> NMW_Location.basin + footage_ns = mapped_column("Footage_NS", Float) # -> NMW_Location.footage_n_s + nors_fdir = mapped_column("NorS_FDir", String) # -> NMW_Location.direction_n_s + footage_ew = mapped_column("Footage_EW", Float) # -> NMW_Location.footage_e_w + eorw_fdir = mapped_column("EorW_FDir", String) # -> NMW_Location.direction_e_w + lat_min = mapped_column("Lat_min", SmallInteger) # Drop (mostly empty) + lat_sec = mapped_column("Lat_sec", Float) # Drop (mostly empty) + long_deg = mapped_column("Long_deg", SmallInteger) # Drop (mostly empty) + long_min = mapped_column("Long_min", SmallInteger) # Drop (mostly empty) + long_sec = mapped_column("Long_sec", Float) # Drop (mostly empty) + lat_dd27 = mapped_column("Lat_dd27", Float) # -> NMW_Location.latitutde_dd27 + long_dd27 = mapped_column("Long_dd27", Float) # -> NMW_Location.longitude_dd27 + lat_dd83 = mapped_column("Lat_dd83", Float) # -> location.point + long_dd83 = mapped_column("Long_dd83", Float) # -> location.point + source_id = mapped_column("SourceID", String) # -> publication.id + source_datum = mapped_column("SourceDatum", String) # -> NMW_Location.source_datum + source_units = mapped_column("SourceUnits", String) # -> NMW_Location.source_units + loc_acc_type = mapped_column("LocAccType", String) # Drop + loc_acc_meas = mapped_column("LocAccMeas", String) # Drop + loc_acc_val = mapped_column("LocAccVal", Float) # Drop + duplicated = mapped_column("Duplicated", SmallInteger) # Drop + exclude = mapped_column("Exclude", SmallInteger) # Drop + comments = mapped_column("Comments", String) # (unmapped) + global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + api = mapped_column("API", String) # Drop + + +class NMW_WellHeaders(Base): + """1:1 mirror of NM_Wells ``tbl_well_headers`` (Main / Migrate First). + + Transform target: ``thing`` (name/type/well_depth/completion_date), + ``status_history``, ``contact`` (operator + owner), ``publication``, + ``thing_geologic_formation_association``, ``thing_id_link.alternate_id``, + plus new ``well_detail`` and ``well_purpose`` tables. + """ + + __tablename__ = "NMW_WellHeaders" + + object_id = mapped_column("OBJECTID", Integer) # Drop + # WellDataID is the key relating header <-> location <-> records. + well_data_id = mapped_column( + "WellDataID", UUID(as_uuid=True), primary_key=True + ) # Keep + well_spot_id = mapped_column( + "WellSpotID", UUID(as_uuid=True) + ) # Drop (purpose unclear) + api = mapped_column("API", String) # -> thing_id_link.alternate_id + well_class = mapped_column("WellClass", String) # -> thing.type + well_type = mapped_column("WellType", String) # -> well_purpose.purpose + well_orient = mapped_column("WellOrient", String) # -> well_detail.well_orient + cur_well_nam = mapped_column("CurWellNam", String) # -> thing.name + cur_well_num = mapped_column("CurWellNum", String) # -> well_detail.well_number + cur_status = mapped_column("CurStatus", String) # -> status_history.status + prd_pool_cnt = mapped_column("PrdPoolCnt", SmallInteger) # Drop + cur_operatr = mapped_column("CurOperatr", String) # -> contact.name (type=operator) + cur_owner = mapped_column("CurOwner", String) # -> contact.name (type=owner) + total_depth = mapped_column("TotalDepth", Float) # -> thing.well_depth + well_tvd = mapped_column("Well_TVD", Float) # Drop + fm_td = mapped_column("Fm_TD", String) # -> thing_geologic_formation_association.id + age_td = mapped_column("Age_TD", String) # Drop + spud_date = mapped_column("SpudDate", DateTime) # Drop + compl_date = mapped_column("ComplDate", DateTime) # -> thing.well_completion_date + plug_date = mapped_column("PlugDate", DateTime) # Drop + plug_back = mapped_column("PlugBack", Float) # Drop + bridge_plug = mapped_column("BridgePlug", String) # Drop + scout_tickt = mapped_column("ScoutTickt", SmallInteger) # Drop + dwn_hole_sur = mapped_column("DwnHoleSur", SmallInteger) # Drop + geol_log = mapped_column("GeolLog", SmallInteger) # Drop + geophys_log = mapped_column("Geophyslog", SmallInteger) # Drop + gthrm_exist = mapped_column("GthrmExist", SmallInteger) # Drop + petro_data = mapped_column("PetroData", SmallInteger) # Drop + core_exists = mapped_column("CoreExists", SmallInteger) # Drop + cuttings = mapped_column("Cuttings", SmallInteger) # Drop + sample_data = mapped_column("SampleData", SmallInteger) # Drop + comments = mapped_column("Comments", String) # -> well_detail.comments + import_id = mapped_column("Import_ID", String) # Drop + import_db = mapped_column("Import_DB", String) # Drop + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +class NMW_WellRecords(Base): + """1:1 mirror of NM_Wells ``tbl_well_records`` (Main / Migrate First). + + Transform target: ``field_event`` (+ ``field_activity``). The legacy + wells -> records relationship (RecrdSetID) is re-routed to + wells -> field_event during transform. RecrdClass tags which records are + geothermal. + """ + + __tablename__ = "NMW_WellRecords" + + object_id = mapped_column("OBJECTID", Integer) # Drop + recrd_set_id = mapped_column( + "RecrdSetID", UUID(as_uuid=True), primary_key=True + ) # -> field_event.id + well_data_id = mapped_column( + "WellDataID", UUID(as_uuid=True), index=True + ) # FK -> header/location WellDataID + recrd_class = mapped_column("RecrdClass", String) # -> field_activity.activity_type + source_id = mapped_column( + "SourceID", String + ) # -> publication.id (text in source, not a real FK) + action_date = mapped_column("ActionDate", DateTime) # -> field_event.event_date + well_name = mapped_column("WellName", String) # Drop + well_number = mapped_column("WellNumber", String) # Drop + api_suffix = mapped_column("API_suffix", String) # Drop + entered_by = mapped_column("EnteredBy", String) # Drop + entry_date = mapped_column("EntryDate", DateTime) # Drop + comments = mapped_column("Comments", String) # -> field_event.notes + + +class NMW_WellZDatum(Base): + """1:1 mirror of NM_Wells ``tbl_well_z_datum`` (Main / Migrate First). + + Transform target: ``measuring_point_history`` (elevation -> height, + datum -> description, units/source -> new fields). + """ + + __tablename__ = "NMW_WellZDatum" + + object_id = mapped_column("OBJECTID", Integer) # Drop + recrdset_id = mapped_column( + "RecrdsetID", UUID(as_uuid=True), index=True + ) # FK -> records + elev_gl = mapped_column( + "Elev_GL", Float + ) # -> measuring_point_history.measuring_point_height + elev_df = mapped_column( + "Elev_DF", Float + ) # -> measuring_point_history.measuring_point_height + elev_kb = mapped_column( + "Elev_KB", Float + ) # -> measuring_point_history.measuring_point_height + elev_unspc = mapped_column( + "Elev_unspc", Float + ) # -> measuring_point_history.measuring_point_height + datum_elev = mapped_column("DatumElev", Float) # Drop (redundant) + depth_datum = mapped_column( + "DepthDatum", String + ) # -> measuring_point_history.measuring_point_description + depth_units = mapped_column( + "DepthUnits", String + ) # -> measuring_point_history.measuring_point_units [new field] + z_datum = mapped_column("Z_datum", String) # Drop (only 7 records) + z_units = mapped_column("Z_units", String) # Drop + elev_source = mapped_column( + "ElevSource", String + ) # -> measuring_point_history.source [new field] + elv_acc_type = mapped_column("ElvAccType", String) # Drop + elv_acc_meas = mapped_column("ElvAccMeas", String) # Drop + elv_acc_val = mapped_column("ElvAccVal", Float) # Drop + comments = mapped_column("Comments", String) # Drop + # TODO(verify PK): GlobalID assumed PK. + global_id = mapped_column("GlobalID", UUID(as_uuid=True), primary_key=True) # Drop + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +class NMW_WellSamples(Base): + """1:1 mirror of NM_Wells ``tbl_well_samples`` (Main / Migrate First). + + Transform target: ``sample`` (date/notes/created_by) + ``observation`` + (depth units). The many boolean attribute flags (Porosity, Geothermal, + etc.) are dropped (mostly empty in source). + """ + + __tablename__ = "NMW_WellSamples" + + object_id = mapped_column("OBJECTID", Integer) # Drop + sampl_set_id = mapped_column( + "SamplSetID", UUID(as_uuid=True), primary_key=True + ) # -> sample.id + recrdset_id = mapped_column( + "RecrdsetID", UUID(as_uuid=True), index=True + ) # -> field_activity.id + smp_set_name = mapped_column("SmpSetName", String) # Drop + sampl_class = mapped_column("SamplClass", String) # Drop (mostly 'data') + sample_type = mapped_column("SampleType", String) # Drop (mostly empty) + sample_fm = mapped_column("SampleFm", String) # Drop (mostly empty) + sample_loc = mapped_column("SampleLoc", String) # Drop (no entries) + sample_date = mapped_column("SampleDate", DateTime) # -> sample.sample_date + from_depth = mapped_column("From_Depth", Float) # -> observation (depth) + to_depth = mapped_column("To_Depth", Float) # -> observation (depth) + smp_dp_unt = mapped_column("SmpDpUnt", String) # -> observation.unit + from_tvd = mapped_column("From_TVD", Float) # Drop + to_tvd = mapped_column("To_TVD", Float) # Drop + from_elev = mapped_column("From_Elev", Float) # Drop (empty) + to_elev = mapped_column("To_Elev", Float) # Drop (empty) + porosity = mapped_column("Porosity", SmallInteger) # Drop + permeablty = mapped_column("Permeablty", SmallInteger) # Drop + density = mapped_column("Density", SmallInteger) # Drop + dst_tests = mapped_column("DST_Tests", SmallInteger) # Drop + thin_sect = mapped_column("ThinSect", SmallInteger) # Drop + geochron = mapped_column("Geochron", SmallInteger) # Drop + geochem = mapped_column("Geochem", SmallInteger) # Drop + geothermal = mapped_column("Geothermal", SmallInteger) # Drop + whole_rock = mapped_column("WholeRock", SmallInteger) # Drop + paleontlgy = mapped_column("Paleontlgy", SmallInteger) # Drop + entered_by = mapped_column("EnteredBy", String) # -> sample.created_by_name + entry_date = mapped_column("EntryDate", DateTime) # -> sample.created_at + notes = mapped_column("Notes", String) # -> sample.notes + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +# ============================================================================= +# GEOTHERMAL (Area=Geothermal, "Migrate First") +# ============================================================================= + + +class NMW_GtBhtHeaders(Base): + """1:1 mirror of NM_Wells ``tbl_gt_bht_headers`` (bottom-hole-temp header).""" + + __tablename__ = "NMW_GtBhtHeaders" + + object_id = mapped_column("OBJECTID", Integer) # Drop (identity) + bht_guid = mapped_column("BHTGUID", UUID(as_uuid=True), primary_key=True) + sampl_set_id = mapped_column( + "SamplSetID", UUID(as_uuid=True), index=True + ) # FK -> well_samples.SamplSetID + bore_dia = mapped_column("BoreDia", Float) + bore_units = mapped_column("BoreUnits", String(16)) + drill_fluid = mapped_column("DrillFluid", String(16)) + temp_unit = mapped_column("TempUnit", String(1)) + fld_salinity = mapped_column("FldSalinity", Float) + fld_rstvity = mapped_column("FldRstvity", Float) + fluid_ph = mapped_column("Fluid_pH", Float) + fld_density = mapped_column("FldDensity", Float) + fld_level = mapped_column("FldLevel", Float) + fld_viscsty = mapped_column("FldViscsty", Float) + fluid_loss = mapped_column("FluidLoss", String(50)) + notes = mapped_column("Notes", String(255)) + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +class NMW_GtBhtData(Base): + """1:1 mirror of NM_Wells ``tbl_gt_bht_data`` (BHT readings).""" + + __tablename__ = "NMW_GtBhtData" + + object_id = mapped_column("OBJECTID", Integer, primary_key=True) # identity PK + bht_guid = mapped_column( + "BHTGUID", UUID(as_uuid=True), index=True + ) # FK -> gt_bht_headers.BHTGUID + depth = mapped_column("Depth", Float) + bht = mapped_column("BHT", Float) + temp_unit = mapped_column("TempUnit", String(5)) + hrs_snce_cir = mapped_column("HrsSnceCir", Float) + date_measrd = mapped_column("DateMeasrd", DateTime) + comments = mapped_column("Comments", String(255)) + global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +class NMW_WsIntervals(Base): + """1:1 mirror of NM_Wells ``tbl_ws_intervals`` (sample depth intervals).""" + + __tablename__ = "NMW_WsIntervals" + + object_id = mapped_column("OBJECTID", Integer) # Drop (identity) + intrvl_guid = mapped_column("IntrvlGUID", UUID(as_uuid=True), primary_key=True) + sampl_set_id = mapped_column( + "SamplSetID", UUID(as_uuid=True), index=True + ) # FK -> well_samples.SamplSetID + sample_id = mapped_column("SampleID", String(128)) + from_depth = mapped_column("From_Depth", Float) + to_depth = mapped_column("To_Depth", Float) + from_tvd = mapped_column("From_TVD", Float) + to_tvd = mapped_column("To_TVD", Float) + from_elev = mapped_column("From_Elev", Float) + to_elev = mapped_column("To_Elev", Float) + intv_notes = mapped_column("Intv_Notes", String(255)) + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +class NMW_GtConductivity(Base): + """1:1 mirror of NM_Wells ``tbl_gt_conductivity`` (thermal conductivity).""" + + __tablename__ = "NMW_GtConductivity" + + object_id = mapped_column("OBJECTID", Integer, primary_key=True) # identity PK + intrvl_guid = mapped_column( + "IntrvlGUID", UUID(as_uuid=True), index=True + ) # FK -> ws_intervals.IntrvlGUID + cnductvity = mapped_column("Cnductvity", Float) + cnduct_unit = mapped_column("CnductUnit", String(3)) + comments = mapped_column("Comments", String(255)) + global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +class NMW_GtHeatFlow(Base): + """1:1 mirror of NM_Wells ``tbl_gt_heat_flow`` (per-interval heat flow).""" + + __tablename__ = "NMW_GtHeatFlow" + + object_id = mapped_column("OBJECTID", Integer, primary_key=True) # identity PK + intrvl_guid = mapped_column( + "IntrvlGUID", UUID(as_uuid=True), index=True + ) # FK -> ws_intervals.IntrvlGUID + gradient = mapped_column("Gradient", Float) + ka = mapped_column("Ka", Float) + ka_unit = mapped_column("Ka_unit", String(3)) + pm = mapped_column("Pm", Float) + kpr = mapped_column("Kpr", Float) + kpr_unit = mapped_column("Kpr_unit", String(3)) + q = mapped_column("Q", Float) + q_unit = mapped_column("Q_unit", String(3)) + comments = mapped_column("Comments", String(255)) + global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +class NMW_GtSumHeatFlow(Base): + """1:1 mirror of NM_Wells ``tbl_gt_sum_heat_flow`` (summary heat flow).""" + + __tablename__ = "NMW_GtSumHeatFlow" + + object_id = mapped_column("OBJECTID", Integer, primary_key=True) # identity PK + recrd_set_id = mapped_column( + "RecrdSetID", UUID(as_uuid=True), index=True + ) # FK -> well_records.RecrdSetID + sampl_set_id = mapped_column( + "SamplSetID", UUID(as_uuid=True), index=True + ) # FK -> well_samples.SamplSetID + lith_class = mapped_column("LithClass", String(50)) + unit_basis = mapped_column("UnitBasis", String(16)) + unit_name = mapped_column("UnitName", String(128)) + geo_id = mapped_column("GeoID", String(16)) + from_depth = mapped_column("FromDepth", Float) + to_depth = mapped_column("ToDepth", Float) + depth_unit = mapped_column("DepthUnit", String(8)) + from_elev = mapped_column("From_Elev", Float) + to_elev = mapped_column("To_Elev", Float) + therml_grad = mapped_column("ThermlGrad", Float) + tg_error = mapped_column("TGError", Float) + grad_unit = mapped_column("GradUnit", String(3)) + tgrad_range = mapped_column("TGradRange", String(15)) + sample_type = mapped_column("SampleType", String(50)) + num_samples = mapped_column("NumSamples", SmallInteger) + therml_cond = mapped_column("ThermlCond", Float) + tcond_error = mapped_column("TCondError", Float) + tcond_unit = mapped_column("TCondUnit", String(3)) + tcond_range = mapped_column("TCondRange", String(15)) + heat_flow = mapped_column("HeatFlow", Float) + ht_flow_err = mapped_column("HtFlowErr", Float) + ht_flow_unit = mapped_column("HtFlowUnit", String(3)) + ht_flow_est = mapped_column("HtFlowEst", Float) + quality = mapped_column("Quality", String(50)) + comments = mapped_column("Comments", String(255)) + global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +class NMW_GtTempDepths(Base): + """1:1 mirror of NM_Wells ``tbl_gt_temp_depths`` (temp-vs-depth profile).""" + + __tablename__ = "NMW_GtTempDepths" + + object_id = mapped_column("OBJECTID", Integer, primary_key=True) # identity PK + sampl_set_id = mapped_column( + "SamplSetID", UUID(as_uuid=True), index=True + ) # FK -> well_samples.SamplSetID + depth = mapped_column("Depth", Float) + temp = mapped_column("Temp", Float) + temp_unit = mapped_column("TempUnit", String(1)) + intrvl_grad = mapped_column("IntrvlGrad", Float) + comments = mapped_column("Comments", String(255)) + global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +# ============================================================================= +# DRILL STEM TESTS (Area=Drill Stem Tests, "Migrate First") +# ============================================================================= + + +class NMW_WsDstHeaders(Base): + """1:1 mirror of NM_Wells ``tbl_ws_dst_headers`` (DST header).""" + + __tablename__ = "NMW_WsDstHeaders" + + object_id = mapped_column("OBJECTID", Integer) # Drop (identity) + dst_guid = mapped_column("DSTGUID", UUID(as_uuid=True), primary_key=True) + sampl_set_id = mapped_column( + "SamplSetID", UUID(as_uuid=True), index=True + ) # FK -> well_samples.SamplSetID + test_type = mapped_column("TestType", String(50)) + dst_oprator = mapped_column("DSTOprator", String(50)) + press_units = mapped_column("PressUnits", String(8)) + temp_unit = mapped_column("TempUnit", String(1)) + pipe_dia_unt = mapped_column("PipeDiaUnt", String(8)) + pipe_len_unt = mapped_column("PipeLenUnt", String(8)) + choke_siz_un = mapped_column("ChokeSizUn", String(8)) + notes = mapped_column("Notes", String(255)) + + +class NMW_WsDstIntervals(Base): + """1:1 mirror of NM_Wells ``tbl_ws_dst_intervals`` (DST interval).""" + + __tablename__ = "NMW_WsDstIntervals" + + object_id = mapped_column("OBJECTID", Integer) # Drop (identity) + dst_interval = mapped_column("DSTInterval", UUID(as_uuid=True), primary_key=True) + dst_guid = mapped_column( + "DSTGUID", UUID(as_uuid=True), index=True + ) # FK -> ws_dst_headers.DSTGUID + dst_name = mapped_column("DSTName", String(128)) + target_fm = mapped_column("TargetFm", String(16)) + dst_date = mapped_column("DSTDate", DateTime) + dst_number = mapped_column("DSTNumber", SmallInteger) + status = mapped_column("Status", String(255)) + status_date = mapped_column("StatusDate", DateTime) + packr_from = mapped_column("PackrFrom", Float) + packer_to = mapped_column("PackerTo", Float) + srf_choke_sz = mapped_column("SrfChokeSz", Float) + bot_choke_sz = mapped_column("BotChokeSz", Float) + pipe_dia = mapped_column("PipeDia", Float) + pipe_length = mapped_column("PipeLength", Float) + notes = mapped_column("Notes", String(255)) + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +class NMW_WsDstFlowHistory(Base): + """1:1 mirror of NM_Wells ``tbl_ws_dst_flow_history`` (DST flow events).""" + + __tablename__ = "NMW_WsDstFlowHistory" + + object_id = mapped_column("OBJECTID", Integer, primary_key=True) # identity PK + dst_interval = mapped_column( + "DSTInterval", UUID(as_uuid=True), index=True + ) # FK -> ws_dst_intervals.DSTInterval + operation = mapped_column("Operation", String(255)) + start_time = mapped_column("StartTime", DateTime) + end_time = mapped_column("EndTime", DateTime) + duration = mapped_column("Duration", Float) + pressure = mapped_column("Pressure", Float) + temp = mapped_column("Temp", Float) + recov_colmn = mapped_column("RecovColmn", Float) + recov_type = mapped_column("RecovType", String(255)) + notes = mapped_column("Notes", String(255)) + global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +class NMW_WsDstFluidProperties(Base): + """1:1 mirror of NM_Wells ``tbl_ws_dst_fluid_properties`` (recovered fluid).""" + + __tablename__ = "NMW_WsDstFluidProperties" + + object_id = mapped_column("OBJECTID", Integer, primary_key=True) # identity PK + dst_interval = mapped_column( + "DSTInterval", UUID(as_uuid=True), index=True + ) # FK -> ws_dst_intervals.DSTInterval + source_loc = mapped_column("SourceLoc", String(255)) + resistivty = mapped_column("Resistivty", Float) + temp = mapped_column("Temp", Float) + chlorides = mapped_column("Chlorides", Float) + notes = mapped_column("Notes", String(255)) + global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +class NMW_WsDstPressure(Base): + """1:1 mirror of NM_Wells ``tbl_ws_dst_pressure`` (DST pressure readings).""" + + __tablename__ = "NMW_WsDstPressure" + + object_id = mapped_column("OBJECTID", Integer, primary_key=True) # identity PK + dst_interval = mapped_column( + "DSTInterval", UUID(as_uuid=True), index=True + ) # FK -> ws_dst_intervals.DSTInterval + prs_gage_dpt = mapped_column("PrsGageDpt", Float) + blanked_off = mapped_column("BlankedOff", SmallInteger) + in_sht_in_min = mapped_column("InShtInMin", Float) + flw_prs_in_min = mapped_column("FlwPrsInMin", Float) + prs_in_sht_in = mapped_column("PrsInShtIn", Float) + prs_init_clsd_in = mapped_column("PrsInitClsdIn", Float) + fn_sht_in_min = mapped_column("FnShtInMin", Float) + flw_prs_fin_min = mapped_column("FlwPrsFinMin", Float) + prs_fn_sht_in = mapped_column("PrsFnShtIn", Float) + sht_in_pr_mth = mapped_column("ShtInPrMth", String(255)) + hydrost_prs_in = mapped_column("HydrostPrsIn", Float) + hyd_st_prs_fl = mapped_column("HydStPrsFl", Float) + hydst_pr_mth = mapped_column("HydstPrMth", String(255)) + equil_press = mapped_column("EquilPress", Float) + eql_prs_mth = mapped_column("EqlPrsMth", String(255)) + flow_prs_min = mapped_column("FlowPrsMin", Float) + flow_prs_max = mapped_column("FlowPrsMax", Float) + flow_prs_mth = mapped_column("FlowPrsMth", String(255)) + dst_fluid = mapped_column("DSTFluid", String(128)) + fm_temp = mapped_column("FmTemp", Float) + temp_corrtn = mapped_column("TempCorrtn", Float) + temp_flowng = mapped_column("TempFlowng", Float) + temp_unit = mapped_column("TempUnit", String(5)) + notes = mapped_column("Notes", String(255)) + global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop + ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) + + +# ============================================================================= +# TODO(remaining "Migrate First" tables, no DDL/mapping yet) +# ----------------------------------------------------------------------------- +# Publications: tbl_sources +# Subsurface Library: dst_scan, log_scanned, Well_Header, well_operators +# See docs/nm_wells-migration.md for the full inventory + recommendations. +# ============================================================================= + + +# ============= EOF ============================================= diff --git a/transfers/nmw_mirror_transfer.py b/transfers/nmw_mirror_transfer.py new file mode 100644 index 000000000..6718cfb7d --- /dev/null +++ b/transfers/nmw_mirror_transfer.py @@ -0,0 +1,250 @@ +# =============================================================================== +# 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. +# =============================================================================== +"""Load the NM_Wells SQL dump into the ``NMW_*`` 1:1 staging mirror tables. + +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 +export is read and its rows are inserted into the matching ``NMW_*`` mirror +model with NO transformation beyond type coercion. The Phase 2 transform into +the Ocotillo model is separate. + +Generic + data-driven: one ``MirrorSpec`` per (model, source CSV). Column +handling is derived from each model's ``__table__`` metadata, so adding a new +mirror table requires only a model + a spec entry (no per-table code). + +Source CSVs are read with ``transfers.util.read_csv`` (looks in +``transfers/data/nma_csv_cache/.csv`` then GCS ``nma_csv/
.csv``). +CSV headers are expected to be the original SQL Server column names (OBJECTID, +WellDataID, GlobalID, ...), which match the mirror columns' DB names exactly. + +Idempotent: rows upsert via ``INSERT ... ON CONFLICT () DO NOTHING``. +""" + +import uuid +from dataclasses import dataclass + +import pandas as pd +from sqlalchemy import DateTime, Float, Integer, LargeBinary, SmallInteger, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.orm import Session + +from db.nmw_legacy import ( + NMW_GtBhtData, + NMW_GtBhtHeaders, + NMW_GtConductivity, + NMW_GtHeatFlow, + NMW_GtSumHeatFlow, + NMW_GtTempDepths, + NMW_WellHeaders, + NMW_WellLocations, + NMW_WellRecords, + NMW_WellSamples, + NMW_WellZDatum, + NMW_WsDstFlowHistory, + NMW_WsDstFluidProperties, + NMW_WsDstHeaders, + NMW_WsDstIntervals, + NMW_WsDstPressure, + NMW_WsIntervals, +) +from transfers.logger import logger +from transfers.util import read_csv + +_CHUNK_SIZE = 2000 + + +@dataclass +class MirrorSpec: + """Maps a mirror model to its NM_Wells source CSV/table name.""" + + model: type + source_table: str + + +# All NMW_* mirror tables. Order is irrelevant (no enforced cross-table FKs in +# the staging layer), but parents are listed before children for readability. +NMW_MIRROR_SPECS: list[MirrorSpec] = [ + # Main + MirrorSpec(NMW_WellLocations, "tbl_well_locations"), + MirrorSpec(NMW_WellHeaders, "tbl_well_headers"), + MirrorSpec(NMW_WellRecords, "tbl_well_records"), + MirrorSpec(NMW_WellZDatum, "tbl_well_z_datum"), + MirrorSpec(NMW_WellSamples, "tbl_well_samples"), + # Geothermal + MirrorSpec(NMW_GtBhtHeaders, "tbl_gt_bht_headers"), + MirrorSpec(NMW_GtBhtData, "tbl_gt_bht_data"), + MirrorSpec(NMW_WsIntervals, "tbl_ws_intervals"), + MirrorSpec(NMW_GtConductivity, "tbl_gt_conductivity"), + MirrorSpec(NMW_GtHeatFlow, "tbl_gt_heat_flow"), + MirrorSpec(NMW_GtSumHeatFlow, "tbl_gt_sum_heat_flow"), + MirrorSpec(NMW_GtTempDepths, "tbl_gt_temp_depths"), + # Drill Stem Tests + MirrorSpec(NMW_WsDstHeaders, "tbl_ws_dst_headers"), + MirrorSpec(NMW_WsDstIntervals, "tbl_ws_dst_intervals"), + MirrorSpec(NMW_WsDstFlowHistory, "tbl_ws_dst_flow_history"), + MirrorSpec(NMW_WsDstFluidProperties, "tbl_ws_dst_fluid_properties"), + MirrorSpec(NMW_WsDstPressure, "tbl_ws_dst_pressure"), +] + + +def _coerce(value, col_type): + """Coerce a single cell to the Python value for ``col_type`` (or None). + + Treats NaN/NaT as None. (pandas keeps NaN/NaT in typed columns even after a + ``.where(notnull, None)``, so the missing-value check must happen here.) + """ + if value is None: + return None + try: + if pd.isna(value): + return None + except (TypeError, ValueError): + pass # non-scalar / unhashable: fall through and coerce normally + if isinstance(col_type, UUID): + if isinstance(value, uuid.UUID): + return value + try: + return uuid.UUID(str(value).strip()) + except (ValueError, AttributeError, TypeError): + return None + if isinstance(col_type, (Integer, SmallInteger)): + try: + return int(value) + except (ValueError, TypeError): + return None + if isinstance(col_type, Float): + try: + return float(value) + except (ValueError, TypeError): + return None + if isinstance(col_type, DateTime): + # pandas Timestamp -> python datetime; anything else passed through. + return value.to_pydatetime() if hasattr(value, "to_pydatetime") else value + if isinstance(col_type, String): + s = str(value) + return s[: col_type.length] if col_type.length else s + # Fallback (should not hit for our mirror types). + return value + + +def _load_table(session: Session, spec: MirrorSpec, limit: int = 0) -> dict: + """Load one source CSV into its mirror table. Returns a stats dict.""" + table = spec.model.__table__ + name = spec.source_table + + try: + df = read_csv(name) + except Exception as e: # noqa: BLE001 - missing CSV / GCS miss must not abort + logger.warning("Skipping %s (could not read CSV): %s", name, e) + return {"table": name, "skipped": True, "reason": str(e)} + + if limit and limit > 0: + df = df.head(limit) + if df.empty: + logger.warning("Skipping %s (empty)", name) + return {"table": name, "skipped": True, "reason": "empty"} + + # Columns to load = mirror columns present in the CSV, excluding rowversion + # (LargeBinary) which is a SQL Server artifact with no meaningful CSV value. + cols = {c.name: c for c in table.columns if not isinstance(c.type, LargeBinary)} + present = [n for n in df.columns if n in cols] + missing_csv = [n for n in cols if n not in df.columns] + extra_csv = [n for n in df.columns if n not in cols] + if not present: + logger.warning( + "Skipping %s: no overlapping columns (csv has %s)", name, list(df.columns) + ) + return {"table": name, "skipped": True, "reason": "no matching columns"} + if missing_csv: + logger.warning("%s: mirror columns absent from CSV: %s", name, missing_csv) + if extra_csv: + logger.info("%s: ignoring %d unmapped CSV column(s)", name, len(extra_csv)) + + pk_cols = [c.name for c in table.primary_key] + # NaN/NaT are normalized to None inside _coerce (pandas keeps them in typed + # columns), so the raw dict records are fine here. + records = df[present].to_dict("records") + total = len(records) + inserted = 0 + + for start in range(0, total, _CHUNK_SIZE): + chunk = records[start : start + _CHUNK_SIZE] + rows = [] + for rec in chunk: + row = {n: _coerce(rec.get(n), cols[n].type) for n in present} + # Drop rows missing a PK value (cannot upsert). + if any(row.get(pk) is None for pk in pk_cols): + continue + rows.append(row) + if not rows: + continue + stmt = ( + pg_insert(spec.model) + .values(rows) + .on_conflict_do_nothing(index_elements=pk_cols) + ) + result = session.execute(stmt) + session.commit() + inserted += result.rowcount if result.rowcount and result.rowcount > 0 else 0 + + logger.info( + "Mirror %s -> %s: %d source rows, %d inserted", + name, + table.name, + total, + inserted, + ) + return { + "table": name, + "skipped": False, + "rows": total, + "inserted": inserted, + } + + +def transfer_nmw_mirror(session: Session, limit: int = None) -> tuple: + """Load all NM_Wells source CSVs into the ``NMW_*`` staging mirror. + + Same ``(session, limit)`` signature as the other session-based transfers. + Returns ``(num_tables_loaded, total_rows_inserted, errors)``. + """ + limit = int(limit or 0) + results = [] + errors = [] + for spec in NMW_MIRROR_SPECS: + try: + results.append(_load_table(session, spec, limit)) + except Exception as e: # noqa: BLE001 - isolate per-table failures + logger.critical("NMW mirror load failed for %s: %s", spec.source_table, e) + session.rollback() + errors.append({"table": spec.source_table, "error": str(e)}) + + loaded = [r for r in results if not r.get("skipped")] + skipped = [r for r in results if r.get("skipped")] + inserted = sum(r.get("inserted", 0) for r in loaded) + logger.info( + "NMW mirror load complete: %d tables loaded, %d skipped, %d rows inserted, " + "%d errors", + len(loaded), + len(skipped), + inserted, + len(errors), + ) + return len(loaded), inserted, errors + + +# ============= EOF ============================================= diff --git a/transfers/reference_lexicon_transfer.py b/transfers/reference_lexicon_transfer.py new file mode 100644 index 000000000..13c64e62b --- /dev/null +++ b/transfers/reference_lexicon_transfer.py @@ -0,0 +1,362 @@ +# =============================================================================== +# 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. +# =============================================================================== +"""Load the legacy NM_Wells ``ref_*`` reference tables into the lexicon. + +The planning workbook ("NM_Wells + Subsurface library.xlsx", sheet 1) flags the +``ref_*`` tables as "Add to lexicon". Each ref table is a small code/description +lookup; this transfer loads its rows as ``LexiconTerm`` rows and links them to a +``LexiconCategory`` named after the table (``ref_well_class`` -> ``well_class``). + +Idempotent: mirrors ``core.initializers.init_lexicon`` — categories and terms +upsert via ``ON CONFLICT DO NOTHING`` (both ``name``/``term`` are unique); +term<->category associations are inserted only when missing (no unique +constraint exists on that table). + +Source CSVs are read with ``transfers.util.read_csv`` (looks in +``transfers/data/nma_csv_cache/
.csv`` then GCS ``nma_csv/
.csv``). + +NOTE(columns): the ref tables' actual column names are not in the workbook, so +term/definition columns are AUTO-DETECTED per table (see ``_pick_columns``). If +auto-detection is wrong for a table, set ``term_col`` / ``definition_col`` +explicitly on its ``RefTableSpec`` below. The chosen columns are logged. + +NOTE(LU_*): the Subsurface Library ``LU_*`` lookups (LU_EnteredBy, LU_LogType, +LU_Status, LU_Type_Wellheader, LU_WorkType) are also "Add to lexicon"; add them +to ``REFERENCE_TABLE_SPECS`` once their CSVs are available. +""" + +from dataclasses import dataclass +from typing import Optional + +import pandas as pd +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.orm import Session + +from db import ( + LexiconCategory, + LexiconTerm, + LexiconTermCategoryAssociation, +) +from transfers.logger import logger +from transfers.util import read_csv + +# lexicon_term.term (and its FK targets) is String(100). +_TERM_MAX_LEN = 100 + +# Column-name hints for auto-detecting the code/term vs definition columns. +_META_COLS = {"objectid", "ssma_timestamp", "globalid", "id", "import_id"} +_TERM_HINTS = ("code", "abbr", "symbol", "letter", "key", "short") +_DEF_HINTS = ( + "description", + "desc", + "definition", + "meaning", + "label", + "name", + "long", + "title", + "value", + "text", +) + + +@dataclass +class RefTableSpec: + """One legacy ref table -> one lexicon category. + + term_col / definition_col are optional overrides; when None the columns are + auto-detected from the CSV header. + """ + + source_table: str + category: str + term_col: Optional[str] = None + definition_col: Optional[str] = None + description: Optional[str] = None + + +def _spec(table: str) -> RefTableSpec: + """Build a spec with category = table name minus the ``ref_`` prefix.""" + category = table[4:] if table.startswith("ref_") else table + return RefTableSpec( + source_table=table, + category=category, + description=f"Imported from NM_Wells {table}", + ) + + +# All ref_* tables marked "Add to lexicon" in the workbook (sheet 1). +# ref_nm_quads (Review) and ref_date_drilled-style oddities are intentionally +# excluded; add/remove specs here as the mapping is refined. +REFERENCE_TABLE_SPECS: list[RefTableSpec] = [ + _spec(t) + for t in ( + "ref_altitude_datums", + "ref_altitude_methods", + "ref_basins", + "ref_coordinate_accuracy", + "ref_coordinate_datum", + "ref_coordinate_method", + "ref_county", + "ref_data_reliability", + "ref_date_drilled", + "ref_depth_types", + "ref_display_scales", + "ref_ground_levels", + "ref_gt_data_sources", + "ref_gt_well_types", + "ref_ign_comps", + "ref_indurations", + "ref_initials", + "ref_length_units", + "ref_lith_class", + "ref_lith_types", + "ref_ll_sources", + "ref_mm_facies", + "ref_perforation_types", + "ref_porosity_methods", + "ref_pres_units", + "ref_prod_meth_quality", + "ref_prod_methods", + "ref_prod_units", + "ref_sample_class", + "ref_sample_types", + "ref_states", + "ref_textures", + "ref_unit_basis", + "ref_unit_conductivity", + "ref_unit_depths", + "ref_unit_gradients", + "ref_unit_heat_flow", + "ref_unit_letters", + "ref_unit_temps", + "ref_well_action_class", + "ref_well_class", + "ref_well_commodity", + "ref_well_log_class", + "ref_well_orientations", + "ref_well_record_class", + "ref_well_status", + "ref_well_types", + "ref_work_types", + "ref_xy_units", + ) +] + + +def _pick_columns(df: pd.DataFrame, spec: RefTableSpec) -> tuple[str, str]: + """Resolve (term_col, definition_col) for a ref table. + + Honors explicit overrides on the spec, else auto-detects from the header + using name hints, ignoring meta columns (OBJECTID, GlobalID, ...). + """ + cols = [c for c in df.columns if str(c).strip().lower() not in _META_COLS] + if not cols: + cols = list(df.columns) + low = {c: str(c).strip().lower() for c in cols} + + term_col = spec.term_col + if term_col is None: + term_col = next( + (c for c in cols if any(h in low[c] for h in _TERM_HINTS)), cols[0] + ) + + def_col = spec.definition_col + if def_col is None: + def_col = next( + (c for c in cols if c != term_col and any(h in low[c] for h in _DEF_HINTS)), + None, + ) + if def_col is None: + def_col = cols[1] if len(cols) > 1 else term_col + + return term_col, def_col + + +def _clean(value) -> Optional[str]: + if value is None or pd.isna(value): + return None + s = str(value).strip() + return s or None + + +def _get_or_create_category(session: Session, spec: RefTableSpec) -> int: + """Return the lexicon_category.id for the spec, creating it if needed.""" + cat_id = session.execute( + select(LexiconCategory.id).where(LexiconCategory.name == spec.category) + ).scalar_one_or_none() + if cat_id is not None: + return cat_id + + session.execute( + pg_insert(LexiconCategory) + .values(name=spec.category, description=spec.description) + .on_conflict_do_nothing(index_elements=["name"]) + ) + session.commit() + return session.execute( + select(LexiconCategory.id).where(LexiconCategory.name == spec.category) + ).scalar_one() + + +def _transfer_one(session: Session, spec: RefTableSpec, limit: int = 0) -> dict: + """Load a single ref table into the lexicon. Returns a stats dict.""" + try: + df = read_csv(spec.source_table) + except Exception as e: # noqa: BLE001 - missing CSV / GCS miss should not abort + logger.warning("Skipping %s (could not read CSV): %s", spec.source_table, e) + return {"table": spec.source_table, "skipped": True, "reason": str(e)} + + if limit and limit > 0: + df = df.head(limit) + + if df.empty or not list(df.columns): + logger.warning("Skipping %s (empty)", spec.source_table) + return {"table": spec.source_table, "skipped": True, "reason": "empty"} + + term_col, def_col = _pick_columns(df, spec) + logger.info( + "%s -> category=%s term_col=%s definition_col=%s (%d rows)", + spec.source_table, + spec.category, + term_col, + def_col, + len(df), + ) + + category_id = _get_or_create_category(session, spec) + + # Build unique (term -> definition) map, dropping empties and overlong terms. + term_defs: dict[str, str] = {} + truncated = 0 + for row in df.itertuples(index=False): + term = _clean(getattr(row, term_col, None)) + if term is None: + continue + if len(term) > _TERM_MAX_LEN: + term = term[:_TERM_MAX_LEN] + truncated += 1 + definition = _clean(getattr(row, def_col, None)) or term + term_defs.setdefault(term, definition) + + if not term_defs: + logger.warning("Skipping %s (no usable terms)", spec.source_table) + return {"table": spec.source_table, "skipped": True, "reason": "no terms"} + if truncated: + logger.warning( + "%s: truncated %d term(s) to %d chars", + spec.source_table, + truncated, + _TERM_MAX_LEN, + ) + + term_names = list(term_defs) + existing_terms = dict( + session.execute( + select(LexiconTerm.term, LexiconTerm.id).where( + LexiconTerm.term.in_(term_names) + ) + ).all() + ) + new_rows = [ + {"term": t, "definition": d} + for t, d in term_defs.items() + if t not in existing_terms + ] + if new_rows: + session.execute( + pg_insert(LexiconTerm) + .values(new_rows) + .on_conflict_do_nothing(index_elements=["term"]) + ) + session.commit() + existing_terms = dict( + session.execute( + select(LexiconTerm.term, LexiconTerm.id).where( + LexiconTerm.term.in_(term_names) + ) + ).all() + ) + + term_ids = [tid for tid in existing_terms.values() if tid is not None] + existing_links = set() + if term_ids: + existing_links = set( + session.execute( + select(LexiconTermCategoryAssociation.term_id).where( + LexiconTermCategoryAssociation.category_id == category_id, + LexiconTermCategoryAssociation.term_id.in_(term_ids), + ) + ).scalars() + ) + + assoc_rows = [ + {"term_id": tid, "category_id": category_id} + for tid in term_ids + if tid not in existing_links + ] + if assoc_rows: + session.execute(pg_insert(LexiconTermCategoryAssociation).values(assoc_rows)) + session.commit() + + return { + "table": spec.source_table, + "skipped": False, + "rows": len(df), + "terms": len(term_defs), + "created_terms": len(new_rows), + "linked": len(assoc_rows), + } + + +def transfer_reference_tables(session: Session, limit: int = None) -> tuple: + """Foundational transfer: load all ``ref_*`` tables into the lexicon. + + Same ``(session, limit)`` signature as the other foundational transfers + (aquifer systems, geologic formations). Returns + ``(num_tables, total_created_terms, errors)``. + """ + limit = int(limit or 0) + results = [] + errors = [] + for spec in REFERENCE_TABLE_SPECS: + try: + results.append(_transfer_one(session, spec, limit)) + except Exception as e: # noqa: BLE001 - isolate per-table failures + logger.critical( + "Reference lexicon transfer failed for %s: %s", spec.source_table, e + ) + session.rollback() + errors.append({"table": spec.source_table, "error": str(e)}) + + loaded = [r for r in results if not r.get("skipped")] + skipped = [r for r in results if r.get("skipped")] + created = sum(r.get("created_terms", 0) for r in loaded) + linked = sum(r.get("linked", 0) for r in loaded) + logger.info( + "Reference lexicon transfer complete: %d tables loaded, %d skipped, " + "%d terms created, %d associations, %d errors", + len(loaded), + len(skipped), + created, + linked, + len(errors), + ) + return len(loaded), created, errors + + +# ============= EOF ============================================= diff --git a/transfers/transfer.py b/transfers/transfer.py index 419d4870a..c42910ada 100644 --- a/transfers/transfer.py +++ b/transfers/transfer.py @@ -55,6 +55,8 @@ from services.env import get_bool_env from transfers.aquifer_system_transfer import transfer_aquifer_systems from transfers.geologic_formation_transfer import transfer_geologic_formations +from transfers.reference_lexicon_transfer import transfer_reference_tables +from transfers.nmw_mirror_transfer import transfer_nmw_mirror from transfers.permissions_transfer import transfer_permissions from transfers.stratigraphy_legacy import StratigraphyLegacyTransferer from transfers.stratigraphy_transfer import transfer_stratigraphy @@ -360,11 +362,12 @@ def transfer_all(metrics: Metrics) -> list[ProfileArtifact]: else: message("PHASE 1: FOUNDATIONAL TRANSFERS (PARALLEL)") foundational_tasks = [ + ("ReferenceLexicon", transfer_reference_tables), ("AquiferSystems", transfer_aquifer_systems), ("GeologicFormations", transfer_geologic_formations), ] - with ThreadPoolExecutor(max_workers=2) as executor: + with ThreadPoolExecutor(max_workers=len(foundational_tasks)) as executor: futures = { executor.submit( _execute_foundational_transfer_with_timing, name, func, limit @@ -383,6 +386,13 @@ def transfer_all(metrics: Metrics) -> list[ProfileArtifact]: logger.critical(f"Foundational transfer {name} failed: {e}") raise # Fail fast - foundational transfers must succeed + # NM_Wells 1:1 staging mirror (separate source DB). Off by default so it + # does not run during the standard NM_Aquifer -> Ocotillo transfer. + if get_bool_env("TRANSFER_NMW_MIRROR", False): + message("NM_WELLS 1:1 STAGING MIRROR LOAD") + with session_ctx() as session: + transfer_nmw_mirror(session, limit=limit) + message("TRANSFERRING WELLS") use_parallel_wells = get_bool_env("TRANSFER_PARALLEL_WELLS", True) if use_parallel_wells: From 9fdb7686fcc1fd545628827266312b24d16b6b4a Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 6 Jun 2026 16:27:31 -0600 Subject: [PATCH 002/160] fix(transfers): address review feedback on NM_Wells mirror - nmw_mirror_transfer: parse DateTime values with pd.to_datetime(errors=coerce) since read_csv does not parse_dates (avoids driver-dependent insert failures). - db/nmw_legacy: fix attribute typos (dst_operator, recov_column, resistivity) while preserving the legacy DB column names; fix latitude_dd27 comment typo. - reference_lexicon_transfer: correct stale exclusion comment (ref_date_drilled is included; only ref_nm_quads is excluded). Co-Authored-By: Claude Opus 4.8 --- db/nmw_legacy.py | 8 ++++---- transfers/nmw_mirror_transfer.py | 8 ++++++-- transfers/reference_lexicon_transfer.py | 4 ++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/db/nmw_legacy.py b/db/nmw_legacy.py index 5186fa66d..1256beddd 100644 --- a/db/nmw_legacy.py +++ b/db/nmw_legacy.py @@ -146,7 +146,7 @@ class NMW_WellLocations(Base): long_deg = mapped_column("Long_deg", SmallInteger) # Drop (mostly empty) long_min = mapped_column("Long_min", SmallInteger) # Drop (mostly empty) long_sec = mapped_column("Long_sec", Float) # Drop (mostly empty) - lat_dd27 = mapped_column("Lat_dd27", Float) # -> NMW_Location.latitutde_dd27 + lat_dd27 = mapped_column("Lat_dd27", Float) # -> NMW_Location.latitude_dd27 long_dd27 = mapped_column("Long_dd27", Float) # -> NMW_Location.longitude_dd27 lat_dd83 = mapped_column("Lat_dd83", Float) # -> location.point long_dd83 = mapped_column("Long_dd83", Float) # -> location.point @@ -523,7 +523,7 @@ class NMW_WsDstHeaders(Base): "SamplSetID", UUID(as_uuid=True), index=True ) # FK -> well_samples.SamplSetID test_type = mapped_column("TestType", String(50)) - dst_oprator = mapped_column("DSTOprator", String(50)) + dst_operator = mapped_column("DSTOprator", String(50)) press_units = mapped_column("PressUnits", String(8)) temp_unit = mapped_column("TempUnit", String(1)) pipe_dia_unt = mapped_column("PipeDiaUnt", String(8)) @@ -573,7 +573,7 @@ class NMW_WsDstFlowHistory(Base): duration = mapped_column("Duration", Float) pressure = mapped_column("Pressure", Float) temp = mapped_column("Temp", Float) - recov_colmn = mapped_column("RecovColmn", Float) + recov_column = mapped_column("RecovColmn", Float) recov_type = mapped_column("RecovType", String(255)) notes = mapped_column("Notes", String(255)) global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop @@ -590,7 +590,7 @@ class NMW_WsDstFluidProperties(Base): "DSTInterval", UUID(as_uuid=True), index=True ) # FK -> ws_dst_intervals.DSTInterval source_loc = mapped_column("SourceLoc", String(255)) - resistivty = mapped_column("Resistivty", Float) + resistivity = mapped_column("Resistivty", Float) temp = mapped_column("Temp", Float) chlorides = mapped_column("Chlorides", Float) notes = mapped_column("Notes", String(255)) diff --git a/transfers/nmw_mirror_transfer.py b/transfers/nmw_mirror_transfer.py index 6718cfb7d..279a25f6f 100644 --- a/transfers/nmw_mirror_transfer.py +++ b/transfers/nmw_mirror_transfer.py @@ -132,8 +132,12 @@ def _coerce(value, col_type): except (ValueError, TypeError): return None if isinstance(col_type, DateTime): - # pandas Timestamp -> python datetime; anything else passed through. - return value.to_pydatetime() if hasattr(value, "to_pydatetime") else value + # read_csv does not parse_dates, so values are typically raw strings. + # Parse explicitly to avoid driver-dependent insert failures. + if hasattr(value, "to_pydatetime"): + return value.to_pydatetime() + ts = pd.to_datetime(value, errors="coerce") + return None if pd.isna(ts) else ts.to_pydatetime() if isinstance(col_type, String): s = str(value) return s[: col_type.length] if col_type.length else s diff --git a/transfers/reference_lexicon_transfer.py b/transfers/reference_lexicon_transfer.py index 13c64e62b..6a20461ae 100644 --- a/transfers/reference_lexicon_transfer.py +++ b/transfers/reference_lexicon_transfer.py @@ -100,8 +100,8 @@ def _spec(table: str) -> RefTableSpec: # All ref_* tables marked "Add to lexicon" in the workbook (sheet 1). -# ref_nm_quads (Review) and ref_date_drilled-style oddities are intentionally -# excluded; add/remove specs here as the mapping is refined. +# ref_nm_quads (Review, ~2k rows) is intentionally excluded; add/remove specs +# here as the mapping is refined. REFERENCE_TABLE_SPECS: list[RefTableSpec] = [ _spec(t) for t in ( From 565c49bbdbd263844fd4e428456c3a41f8d2a7ca Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 6 Jun 2026 22:24:17 -0600 Subject: [PATCH 003/160] refactor(db): drop SSMA_TimeStamp from NM_Wells mirror The SSMA_TimeStamp column is a SQL Server rowversion artifact with no value as staging data (the loader already skipped it). Remove it from the NMW_* mirror models and both migrations; drop the now-unused LargeBinary import. Co-Authored-By: Claude Opus 4.8 --- ...9x0y1z2_nmw_legacy_staging_mirror_tables.py | 4 ---- ...0y1z2a3_nmw_geothermal_dst_mirror_tables.py | 11 ----------- db/nmw_legacy.py | 18 +----------------- 3 files changed, 1 insertion(+), 32 deletions(-) diff --git a/alembic/versions/u7v8w9x0y1z2_nmw_legacy_staging_mirror_tables.py b/alembic/versions/u7v8w9x0y1z2_nmw_legacy_staging_mirror_tables.py index 7fb64962b..b4a78ee34 100644 --- a/alembic/versions/u7v8w9x0y1z2_nmw_legacy_staging_mirror_tables.py +++ b/alembic/versions/u7v8w9x0y1z2_nmw_legacy_staging_mirror_tables.py @@ -71,7 +71,6 @@ def upgrade() -> None: sa.Column("Exclude", sa.SmallInteger(), nullable=True), sa.Column("Comments", sa.String(), nullable=True), sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.Column("API", sa.String(), nullable=True), sa.PrimaryKeyConstraint("OBJECTID"), ) @@ -115,7 +114,6 @@ def upgrade() -> None: sa.Column("Comments", sa.String(), nullable=True), sa.Column("Import_ID", sa.String(), nullable=True), sa.Column("Import_DB", sa.String(), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("WellDataID"), ) @@ -156,7 +154,6 @@ def upgrade() -> None: sa.Column("ElvAccVal", sa.Float(), nullable=True), sa.Column("Comments", sa.String(), nullable=True), sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("GlobalID"), ) op.create_index("ix_NMW_WellZDatum_RecrdsetID", "NMW_WellZDatum", ["RecrdsetID"]) @@ -192,7 +189,6 @@ def upgrade() -> None: sa.Column("EnteredBy", sa.String(), nullable=True), sa.Column("EntryDate", sa.DateTime(), nullable=True), sa.Column("Notes", sa.String(), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("SamplSetID"), ) op.create_index("ix_NMW_WellSamples_RecrdsetID", "NMW_WellSamples", ["RecrdsetID"]) diff --git a/alembic/versions/v8w9x0y1z2a3_nmw_geothermal_dst_mirror_tables.py b/alembic/versions/v8w9x0y1z2a3_nmw_geothermal_dst_mirror_tables.py index d88cd2d3b..2a0bd1a97 100644 --- a/alembic/versions/v8w9x0y1z2a3_nmw_geothermal_dst_mirror_tables.py +++ b/alembic/versions/v8w9x0y1z2a3_nmw_geothermal_dst_mirror_tables.py @@ -56,7 +56,6 @@ def upgrade() -> None: sa.Column("FldViscsty", sa.Float(), nullable=True), sa.Column("FluidLoss", sa.String(length=50), nullable=True), sa.Column("Notes", sa.String(length=255), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("BHTGUID"), ) op.create_index( @@ -74,7 +73,6 @@ def upgrade() -> None: sa.Column("DateMeasrd", sa.DateTime(), nullable=True), sa.Column("Comments", sa.String(length=255), nullable=True), sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("OBJECTID"), ) op.create_index("ix_NMW_GtBhtData_BHTGUID", "NMW_GtBhtData", ["BHTGUID"]) @@ -92,7 +90,6 @@ def upgrade() -> None: sa.Column("From_Elev", sa.Float(), nullable=True), sa.Column("To_Elev", sa.Float(), nullable=True), sa.Column("Intv_Notes", sa.String(length=255), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("IntrvlGUID"), ) op.create_index("ix_NMW_WsIntervals_SamplSetID", "NMW_WsIntervals", ["SamplSetID"]) @@ -105,7 +102,6 @@ def upgrade() -> None: sa.Column("CnductUnit", sa.String(length=3), nullable=True), sa.Column("Comments", sa.String(length=255), nullable=True), sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("OBJECTID"), ) op.create_index( @@ -126,7 +122,6 @@ def upgrade() -> None: sa.Column("Q_unit", sa.String(length=3), nullable=True), sa.Column("Comments", sa.String(length=255), nullable=True), sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("OBJECTID"), ) op.create_index("ix_NMW_GtHeatFlow_IntrvlGUID", "NMW_GtHeatFlow", ["IntrvlGUID"]) @@ -162,7 +157,6 @@ def upgrade() -> None: sa.Column("Quality", sa.String(length=50), nullable=True), sa.Column("Comments", sa.String(length=255), nullable=True), sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("OBJECTID"), ) op.create_index( @@ -182,7 +176,6 @@ def upgrade() -> None: sa.Column("IntrvlGrad", sa.Float(), nullable=True), sa.Column("Comments", sa.String(length=255), nullable=True), sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("OBJECTID"), ) op.create_index( @@ -226,7 +219,6 @@ def upgrade() -> None: sa.Column("PipeDia", sa.Float(), nullable=True), sa.Column("PipeLength", sa.Float(), nullable=True), sa.Column("Notes", sa.String(length=255), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("DSTInterval"), ) op.create_index("ix_NMW_WsDstIntervals_DSTGUID", "NMW_WsDstIntervals", ["DSTGUID"]) @@ -245,7 +237,6 @@ def upgrade() -> None: sa.Column("RecovType", sa.String(length=255), nullable=True), sa.Column("Notes", sa.String(length=255), nullable=True), sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("OBJECTID"), ) op.create_index( @@ -262,7 +253,6 @@ def upgrade() -> None: sa.Column("Chlorides", sa.Float(), nullable=True), sa.Column("Notes", sa.String(length=255), nullable=True), sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("OBJECTID"), ) op.create_index( @@ -300,7 +290,6 @@ def upgrade() -> None: sa.Column("TempUnit", sa.String(length=5), nullable=True), sa.Column("Notes", sa.String(length=255), nullable=True), sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), - sa.Column("SSMA_TimeStamp", sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint("OBJECTID"), ) op.create_index( diff --git a/db/nmw_legacy.py b/db/nmw_legacy.py index 1256beddd..ad5d82a71 100644 --- a/db/nmw_legacy.py +++ b/db/nmw_legacy.py @@ -90,7 +90,7 @@ real / float -> Float nvarchar -> String (source lengths not in the sheet; widened) datetime2 -> DateTime - timestamp -> LargeBinary (SQL Server rowversion; staging only) + timestamp -> dropped (SQL Server rowversion; no value as staging data) TODO(verify): primary keys below are inferred from the mapping sheet / relationship notes, not from source DDL. Confirm against the dump. @@ -100,7 +100,6 @@ DateTime, Float, Integer, - LargeBinary, SmallInteger, String, ) @@ -160,7 +159,6 @@ class NMW_WellLocations(Base): exclude = mapped_column("Exclude", SmallInteger) # Drop comments = mapped_column("Comments", String) # (unmapped) global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) api = mapped_column("API", String) # Drop @@ -214,7 +212,6 @@ class NMW_WellHeaders(Base): comments = mapped_column("Comments", String) # -> well_detail.comments import_id = mapped_column("Import_ID", String) # Drop import_db = mapped_column("Import_DB", String) # Drop - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) class NMW_WellRecords(Base): @@ -291,7 +288,6 @@ class NMW_WellZDatum(Base): comments = mapped_column("Comments", String) # Drop # TODO(verify PK): GlobalID assumed PK. global_id = mapped_column("GlobalID", UUID(as_uuid=True), primary_key=True) # Drop - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) class NMW_WellSamples(Base): @@ -337,7 +333,6 @@ class NMW_WellSamples(Base): entered_by = mapped_column("EnteredBy", String) # -> sample.created_by_name entry_date = mapped_column("EntryDate", DateTime) # -> sample.created_at notes = mapped_column("Notes", String) # -> sample.notes - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) # ============================================================================= @@ -367,7 +362,6 @@ class NMW_GtBhtHeaders(Base): fld_viscsty = mapped_column("FldViscsty", Float) fluid_loss = mapped_column("FluidLoss", String(50)) notes = mapped_column("Notes", String(255)) - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) class NMW_GtBhtData(Base): @@ -386,7 +380,6 @@ class NMW_GtBhtData(Base): date_measrd = mapped_column("DateMeasrd", DateTime) comments = mapped_column("Comments", String(255)) global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) class NMW_WsIntervals(Base): @@ -407,7 +400,6 @@ class NMW_WsIntervals(Base): from_elev = mapped_column("From_Elev", Float) to_elev = mapped_column("To_Elev", Float) intv_notes = mapped_column("Intv_Notes", String(255)) - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) class NMW_GtConductivity(Base): @@ -423,7 +415,6 @@ class NMW_GtConductivity(Base): cnduct_unit = mapped_column("CnductUnit", String(3)) comments = mapped_column("Comments", String(255)) global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) class NMW_GtHeatFlow(Base): @@ -445,7 +436,6 @@ class NMW_GtHeatFlow(Base): q_unit = mapped_column("Q_unit", String(3)) comments = mapped_column("Comments", String(255)) global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) class NMW_GtSumHeatFlow(Base): @@ -486,7 +476,6 @@ class NMW_GtSumHeatFlow(Base): quality = mapped_column("Quality", String(50)) comments = mapped_column("Comments", String(255)) global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) class NMW_GtTempDepths(Base): @@ -504,7 +493,6 @@ class NMW_GtTempDepths(Base): intrvl_grad = mapped_column("IntrvlGrad", Float) comments = mapped_column("Comments", String(255)) global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) # ============================================================================= @@ -555,7 +543,6 @@ class NMW_WsDstIntervals(Base): pipe_dia = mapped_column("PipeDia", Float) pipe_length = mapped_column("PipeLength", Float) notes = mapped_column("Notes", String(255)) - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) class NMW_WsDstFlowHistory(Base): @@ -577,7 +564,6 @@ class NMW_WsDstFlowHistory(Base): recov_type = mapped_column("RecovType", String(255)) notes = mapped_column("Notes", String(255)) global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) class NMW_WsDstFluidProperties(Base): @@ -595,7 +581,6 @@ class NMW_WsDstFluidProperties(Base): chlorides = mapped_column("Chlorides", Float) notes = mapped_column("Notes", String(255)) global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) class NMW_WsDstPressure(Base): @@ -632,7 +617,6 @@ class NMW_WsDstPressure(Base): temp_unit = mapped_column("TempUnit", String(5)) notes = mapped_column("Notes", String(255)) global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop - ssma_timestamp = mapped_column("SSMA_TimeStamp", LargeBinary) # Drop (rowversion) # ============================================================================= From 1f9b1fc0f3fbd4fbf8c7cc8a5896e492319b306d Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 6 Jun 2026 22:28:17 -0600 Subject: [PATCH 004/160] fix(db): verify NM_Wells mirror PKs against dump; z_datum -> OBJECTID Confirmed source PKs from the NM_Wells SQL dump DDL: - WellHeaders/WellRecords/WellSamples have declared PRIMARY KEY constraints (WellDataID / RecrdSetID / SamplSetID) matching the models. - WellLocations and WellZDatum declare no PK, only unique indexes on OBJECTID and GlobalID. Switch WellZDatum PK from GlobalID to OBJECTID for consistency with WellLocations and safety (OBJECTID identity is never NULL; the GlobalID unique index permits one NULL). Update the migration accordingly. Remove the TODO(verify) note; PKs are now confirmed. Co-Authored-By: Claude Opus 4.8 --- ...x0y1z2_nmw_legacy_staging_mirror_tables.py | 6 +++--- db/nmw_legacy.py | 20 +++++++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/alembic/versions/u7v8w9x0y1z2_nmw_legacy_staging_mirror_tables.py b/alembic/versions/u7v8w9x0y1z2_nmw_legacy_staging_mirror_tables.py index b4a78ee34..b413554db 100644 --- a/alembic/versions/u7v8w9x0y1z2_nmw_legacy_staging_mirror_tables.py +++ b/alembic/versions/u7v8w9x0y1z2_nmw_legacy_staging_mirror_tables.py @@ -137,7 +137,7 @@ def upgrade() -> None: op.create_table( "NMW_WellZDatum", - sa.Column("OBJECTID", sa.Integer(), nullable=True), + sa.Column("OBJECTID", sa.Integer(), nullable=False), sa.Column("RecrdsetID", postgresql.UUID(as_uuid=True), nullable=True), sa.Column("Elev_GL", sa.Float(), nullable=True), sa.Column("Elev_DF", sa.Float(), nullable=True), @@ -153,8 +153,8 @@ def upgrade() -> None: sa.Column("ElvAccMeas", sa.String(), nullable=True), sa.Column("ElvAccVal", sa.Float(), nullable=True), sa.Column("Comments", sa.String(), nullable=True), - sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=False), - sa.PrimaryKeyConstraint("GlobalID"), + sa.Column("GlobalID", postgresql.UUID(as_uuid=True), nullable=True), + sa.PrimaryKeyConstraint("OBJECTID"), ) op.create_index("ix_NMW_WellZDatum_RecrdsetID", "NMW_WellZDatum", ["RecrdsetID"]) diff --git a/db/nmw_legacy.py b/db/nmw_legacy.py index ad5d82a71..9d65c7226 100644 --- a/db/nmw_legacy.py +++ b/db/nmw_legacy.py @@ -92,8 +92,14 @@ datetime2 -> DateTime timestamp -> dropped (SQL Server rowversion; no value as staging data) -TODO(verify): primary keys below are inferred from the mapping sheet / -relationship notes, not from source DDL. Confirm against the dump. +PRIMARY KEYS (verified against the NM_Wells SQL dump DDL) +-------------------------------------------------------- +- NMW_WellHeaders -> WellDataID, NMW_WellRecords -> RecrdSetID, + NMW_WellSamples -> SamplSetID: declared PRIMARY KEY constraints in source. +- NMW_WellLocations, NMW_WellZDatum: source declares no PK, only unique indexes + on OBJECTID and GlobalID; OBJECTID (identity, never NULL) is used. +- Geothermal/DST: declared PKs where present (BHTGUID, IntrvlGUID, DSTGUID, + DSTInterval); the rest are heaps keyed on the OBJECTID identity column. """ from sqlalchemy import ( @@ -118,7 +124,8 @@ class NMW_WellLocations(Base): __tablename__ = "NMW_WellLocations" - # TODO(verify PK): tbl has no clear GUID PK; OBJECTID is the identity col. + # No declared PK in source; OBJECTID (identity, unique index, always + # non-null) chosen over the also-unique GlobalID, which permits a NULL. object_id = mapped_column("OBJECTID", Integer, primary_key=True) # Drop well_data_id = mapped_column( "WellDataID", UUID(as_uuid=True), index=True @@ -254,7 +261,9 @@ class NMW_WellZDatum(Base): __tablename__ = "NMW_WellZDatum" - object_id = mapped_column("OBJECTID", Integer) # Drop + # No declared PK in source; OBJECTID (identity, unique index, always + # non-null) chosen over the also-unique GlobalID, which permits a NULL. + object_id = mapped_column("OBJECTID", Integer, primary_key=True) # Drop recrdset_id = mapped_column( "RecrdsetID", UUID(as_uuid=True), index=True ) # FK -> records @@ -286,8 +295,7 @@ class NMW_WellZDatum(Base): elv_acc_meas = mapped_column("ElvAccMeas", String) # Drop elv_acc_val = mapped_column("ElvAccVal", Float) # Drop comments = mapped_column("Comments", String) # Drop - # TODO(verify PK): GlobalID assumed PK. - global_id = mapped_column("GlobalID", UUID(as_uuid=True), primary_key=True) # Drop + global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop class NMW_WellSamples(Base): From cfbf117bbc95441688a2bfedb0024de1138e4cf0 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 6 Jun 2026 22:39:24 -0600 Subject: [PATCH 005/160] feat(transfers): load NM_Wells mirror from a SQL Server data dump Add transfers/nmw_sql_dump.py: streams INSERT [dbo].[tbl_*] (...) VALUES (...) statements out of a SQL Server data-dump .sql file, yielding {column: value} dicts. Handles N'...' / escaped '', embedded commas/parens, CAST(expr AS type), multi-row VALUES, 0x binary -> None, and UTF-16/UTF-8 (BOM auto-detect). Refactor transfer_nmw_mirror to be source-agnostic: when NMW_SQL_DUMP points at a .sql data dump it loads from there, otherwise falls back to per-table CSVs. Same model-driven type coercion and chunked ON CONFLICT upsert for both. Note: the provided NMWells.sql is schema-only; NMW_SQL_DUMP expects a separate data dump containing INSERT statements. Co-Authored-By: Claude Opus 4.8 --- .env.example | 3 + transfers/nmw_mirror_transfer.py | 145 ++++++++++++-------- transfers/nmw_sql_dump.py | 223 +++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+), 57 deletions(-) create mode 100644 transfers/nmw_sql_dump.py diff --git a/.env.example b/.env.example index 2c4534696..8895581b0 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,9 @@ TRANSFER_WEATHER_DATA=True TRANSFER_MINOR_TRACE_CHEMISTRY=True # NM_Wells 1:1 staging mirror load (separate source DB; off by default) TRANSFER_NMW_MIRROR=False +# Optional: path to a NM_Wells SQL Server data-dump .sql file (INSERT statements). +# When set, the mirror loads from it; otherwise it falls back to CSV exports. +# NMW_SQL_DUMP=/path/to/NMWells_data.sql # asset storage GCS_BUCKET_NAME= diff --git a/transfers/nmw_mirror_transfer.py b/transfers/nmw_mirror_transfer.py index 279a25f6f..2b84e39c2 100644 --- a/transfers/nmw_mirror_transfer.py +++ b/transfers/nmw_mirror_transfer.py @@ -21,18 +21,27 @@ model with NO transformation beyond type coercion. The Phase 2 transform into the Ocotillo model is separate. -Generic + data-driven: one ``MirrorSpec`` per (model, source CSV). Column +Generic + data-driven: one ``MirrorSpec`` per (model, source table). Column handling is derived from each model's ``__table__`` metadata, so adding a new mirror table requires only a model + a spec entry (no per-table code). -Source CSVs are read with ``transfers.util.read_csv`` (looks in -``transfers/data/nma_csv_cache/
.csv`` then GCS ``nma_csv/
.csv``). -CSV headers are expected to be the original SQL Server column names (OBJECTID, -WellDataID, GlobalID, ...), which match the mirror columns' DB names exactly. +Two row sources, selected at runtime: + +1. **SQL Server data dump** (preferred): set ``NMW_SQL_DUMP`` to a ``.sql`` file + containing ``INSERT [dbo].[tbl_*] (...) VALUES (...)`` statements. Rows are + streamed and parsed by ``transfers.nmw_sql_dump.iter_table_rows``. +2. **CSV exports** (fallback when ``NMW_SQL_DUMP`` is unset): per-table CSVs read + with ``transfers.util.read_csv`` (``transfers/data/nma_csv_cache/
.csv`` + then GCS ``nma_csv/
.csv``). + +In both cases the source column names are the original SQL Server names +(OBJECTID, WellDataID, ...), which match the mirror columns' DB names exactly. Idempotent: rows upsert via ``INSERT ... ON CONFLICT () DO NOTHING``. """ +import itertools +import os import uuid from dataclasses import dataclass @@ -62,8 +71,12 @@ NMW_WsIntervals, ) from transfers.logger import logger +from transfers.nmw_sql_dump import iter_table_rows from transfers.util import read_csv +# Path to a SQL Server data-dump .sql file. When set, rows are parsed from it; +# otherwise the loader falls back to per-table CSV exports. +_SQL_DUMP_ENV = "NMW_SQL_DUMP" _CHUNK_SIZE = 2000 @@ -145,70 +158,78 @@ def _coerce(value, col_type): return value +def _row_source(spec: MirrorSpec): + """Return ``(iterator_of_raw_dicts, source_label)`` for a spec. + + SQL dump if ``NMW_SQL_DUMP`` is set, otherwise CSV. Raises on a hard read + error so the caller can record/skip the table. + """ + dump = os.getenv(_SQL_DUMP_ENV) + if dump: + return iter_table_rows(dump, spec.source_table), f"sql:{os.path.basename(dump)}" + df = read_csv(spec.source_table) + return (rec for rec in df.to_dict("records")), "csv" + + +def _flush(session: Session, model, rows: list[dict], pk_cols: list[str]) -> int: + """Upsert a batch; return inserted row count.""" + if not rows: + return 0 + stmt = pg_insert(model).values(rows).on_conflict_do_nothing(index_elements=pk_cols) + result = session.execute(stmt) + session.commit() + return result.rowcount if result.rowcount and result.rowcount > 0 else 0 + + def _load_table(session: Session, spec: MirrorSpec, limit: int = 0) -> dict: - """Load one source CSV into its mirror table. Returns a stats dict.""" + """Load one source table (SQL dump or CSV) into its mirror. Stats dict.""" table = spec.model.__table__ name = spec.source_table + # Loadable columns from the model (rowversion/LargeBinary excluded defensively). + cols = {c.name: c for c in table.columns if not isinstance(c.type, LargeBinary)} + pk_cols = [c.name for c in table.primary_key] try: - df = read_csv(name) - except Exception as e: # noqa: BLE001 - missing CSV / GCS miss must not abort - logger.warning("Skipping %s (could not read CSV): %s", name, e) + rows_iter, src = _row_source(spec) + except Exception as e: # noqa: BLE001 - missing source must not abort the run + logger.warning("Skipping %s (could not read source): %s", name, e) return {"table": name, "skipped": True, "reason": str(e)} if limit and limit > 0: - df = df.head(limit) - if df.empty: - logger.warning("Skipping %s (empty)", name) - return {"table": name, "skipped": True, "reason": "empty"} + rows_iter = itertools.islice(rows_iter, limit) - # Columns to load = mirror columns present in the CSV, excluding rowversion - # (LargeBinary) which is a SQL Server artifact with no meaningful CSV value. - cols = {c.name: c for c in table.columns if not isinstance(c.type, LargeBinary)} - present = [n for n in df.columns if n in cols] - missing_csv = [n for n in cols if n not in df.columns] - extra_csv = [n for n in df.columns if n not in cols] - if not present: - logger.warning( - "Skipping %s: no overlapping columns (csv has %s)", name, list(df.columns) - ) - return {"table": name, "skipped": True, "reason": "no matching columns"} - if missing_csv: - logger.warning("%s: mirror columns absent from CSV: %s", name, missing_csv) - if extra_csv: - logger.info("%s: ignoring %d unmapped CSV column(s)", name, len(extra_csv)) - - pk_cols = [c.name for c in table.primary_key] - # NaN/NaT are normalized to None inside _coerce (pandas keeps them in typed - # columns), so the raw dict records are fine here. - records = df[present].to_dict("records") - total = len(records) + total = 0 inserted = 0 + batch: list[dict] = [] + warned_cols = False + for rec in rows_iter: + total += 1 + if not warned_cols: + missing = [n for n in cols if n not in rec] + if missing: + logger.warning( + "%s: mirror columns absent from source: %s", name, missing + ) + warned_cols = True + # NaN/NaT (CSV) and NULL (SQL) normalize to None inside _coerce. + row = {n: _coerce(rec.get(n), cols[n].type) for n in cols if n in rec} + if any(row.get(pk) is None for pk in pk_cols): + continue # cannot upsert without a PK value + batch.append(row) + if len(batch) >= _CHUNK_SIZE: + inserted += _flush(session, spec.model, batch, pk_cols) + batch = [] + inserted += _flush(session, spec.model, batch, pk_cols) - for start in range(0, total, _CHUNK_SIZE): - chunk = records[start : start + _CHUNK_SIZE] - rows = [] - for rec in chunk: - row = {n: _coerce(rec.get(n), cols[n].type) for n in present} - # Drop rows missing a PK value (cannot upsert). - if any(row.get(pk) is None for pk in pk_cols): - continue - rows.append(row) - if not rows: - continue - stmt = ( - pg_insert(spec.model) - .values(rows) - .on_conflict_do_nothing(index_elements=pk_cols) - ) - result = session.execute(stmt) - session.commit() - inserted += result.rowcount if result.rowcount and result.rowcount > 0 else 0 + if total == 0: + logger.warning("Skipping %s (no source rows from %s)", name, src) + return {"table": name, "skipped": True, "reason": "no rows", "source": src} logger.info( - "Mirror %s -> %s: %d source rows, %d inserted", + "Mirror %s -> %s [%s]: %d source rows, %d inserted", name, table.name, + src, total, inserted, ) @@ -217,16 +238,26 @@ def _load_table(session: Session, spec: MirrorSpec, limit: int = 0) -> dict: "skipped": False, "rows": total, "inserted": inserted, + "source": src, } def transfer_nmw_mirror(session: Session, limit: int = None) -> tuple: - """Load all NM_Wells source CSVs into the ``NMW_*`` staging mirror. + """Load all NM_Wells source tables into the ``NMW_*`` staging mirror. - Same ``(session, limit)`` signature as the other session-based transfers. - Returns ``(num_tables_loaded, total_rows_inserted, errors)``. + Source is a SQL dump (``NMW_SQL_DUMP``) when set, else per-table CSVs. Same + ``(session, limit)`` signature as the other session-based transfers. Returns + ``(num_tables_loaded, total_rows_inserted, errors)``. """ limit = int(limit or 0) + dump = os.getenv(_SQL_DUMP_ENV) + if dump: + if not os.path.exists(dump): + raise FileNotFoundError(f"{_SQL_DUMP_ENV} set but file not found: {dump}") + logger.info("NMW mirror source: SQL dump %s", dump) + else: + logger.info("NMW mirror source: CSV exports (set %s for a dump)", _SQL_DUMP_ENV) + results = [] errors = [] for spec in NMW_MIRROR_SPECS: diff --git a/transfers/nmw_sql_dump.py b/transfers/nmw_sql_dump.py new file mode 100644 index 000000000..39637c076 --- /dev/null +++ b/transfers/nmw_sql_dump.py @@ -0,0 +1,223 @@ +# =============================================================================== +# 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. +# =============================================================================== +"""Stream rows out of a SQL Server data-dump ``.sql`` file. + +Parses ``INSERT [dbo].[
] () VALUES ()[, () ...]`` +statements (the format produced by SSMS "Generate Scripts -> data" / ``bcp`` +INSERT mode) for one target table at a time, yielding ``{column: value}`` +dicts. Values are decoded to plain Python: + + NULL -> None + N'...' / '...' -> str (doubled '' unescaped) + 123 / -1.5 -> int / float + CAST(expr AS type) -> the inner expr, recursively + 0x.... -> None (binary / rowversion; not mirrored) + +Type coercion to the target column type happens in nmw_mirror_transfer._coerce, +so this module keeps values loosely typed. + +Streaming: the file is read line by line (constant memory), accumulating across +lines only when a statement's parentheses are unbalanced (strings containing +newlines). The file is scanned once per table. + +Encoding is auto-detected from the BOM (SSMS writes UTF-16 LE); falls back to +utf-8. +""" + +import re +from typing import Iterator, Optional + + +def _detect_encoding(path: str) -> str: + with open(path, "rb") as f: + head = f.read(4) + if head[:2] in (b"\xff\xfe", b"\xfe\xff"): + return "utf-16" + if head[:3] == b"\xef\xbb\xbf": + return "utf-8-sig" + return "utf-8" + + +def _split_top_level(s: str) -> list[str]: + """Split a comma list at paren-depth 0, respecting single-quoted strings.""" + parts: list[str] = [] + buf: list[str] = [] + depth = 0 + in_quote = False + i = 0 + n = len(s) + while i < n: + c = s[i] + if in_quote: + buf.append(c) + if c == "'": + if i + 1 < n and s[i + 1] == "'": # escaped '' + buf.append("'") + i += 2 + continue + in_quote = False + i += 1 + continue + if c == "'": + in_quote = True + buf.append(c) + elif c == "(": + depth += 1 + buf.append(c) + elif c == ")": + depth -= 1 + buf.append(c) + elif c == "," and depth == 0: + parts.append("".join(buf).strip()) + buf = [] + else: + buf.append(c) + i += 1 + if buf: + parts.append("".join(buf).strip()) + return parts + + +def _iter_value_groups(s: str) -> Iterator[str]: + """Yield the inside of each top-level ``( ... )`` group in a VALUES list.""" + depth = 0 + in_quote = False + start = -1 + i = 0 + n = len(s) + while i < n: + c = s[i] + if in_quote: + if c == "'": + if i + 1 < n and s[i + 1] == "'": + i += 2 + continue + in_quote = False + i += 1 + continue + if c == "'": + in_quote = True + elif c == "(": + if depth == 0: + start = i + 1 + depth += 1 + elif c == ")": + depth -= 1 + if depth == 0 and start >= 0: + yield s[start:i] + start = -1 + i += 1 + + +_CAST_RE = re.compile(r"(?is)^CAST\s*\((.*)\s+AS\s+[^)]+\)$") + + +def _parse_value(tok: str): + t = tok.strip() + if not t or t.upper() == "NULL": + return None + m = _CAST_RE.match(t) + if m: + return _parse_value(m.group(1).strip()) + # N'...' or '...' + if t[:1] == "'" or t[:2].upper() == "N'": + q = t.find("'") + inner = t[q + 1 :] + if inner.endswith("'"): + inner = inner[:-1] + return inner.replace("''", "'") + if t[:2].lower() == "0x": # binary / rowversion + return None + if re.fullmatch(r"[-+]?\d+", t): + return int(t) + try: + return float(t) + except ValueError: + return t + + +_INSERT_RE = re.compile( + r"(?is)INSERT\s+(?:\[dbo\]\.)?\[?(?P
\w+)\]?\s*\((?P.*?)\)\s*VALUES\s*(?P.*)$" +) + + +def _balanced(stmt: str) -> bool: + """True if parens are balanced outside single-quoted strings.""" + depth = 0 + in_quote = False + i = 0 + n = len(stmt) + while i < n: + c = stmt[i] + if in_quote: + if c == "'": + if i + 1 < n and stmt[i + 1] == "'": + i += 2 + continue + in_quote = False + elif c == "'": + in_quote = True + elif c == "(": + depth += 1 + elif c == ")": + depth -= 1 + i += 1 + return depth <= 0 and not in_quote + + +def iter_table_rows(path: str, table: str) -> Iterator[dict]: + """Yield ``{column: value}`` dicts for every INSERT into ``table``.""" + enc = _detect_encoding(path) + target = f"[{table}]".lower() + target_plain = table.lower() + pending: Optional[str] = None + + with open(path, encoding=enc, errors="ignore") as f: + for line in f: + if pending is None: + low = line.lower() + if "insert" not in low: + continue + # cheap table filter before the heavier regex + if ( + target not in low + and f"].[{target_plain}]" not in low + and f" {target_plain} " not in low + ): + if target_plain not in low: + continue + pending = line + else: + pending += line + + if not _balanced(pending): + continue # statement spans more lines + + stmt = pending + pending = None + m = _INSERT_RE.search(stmt) + if not m or m.group("table").lower() != target_plain: + continue + cols = [c.strip().strip("[]") for c in _split_top_level(m.group("cols"))] + vals_part = m.group("vals").strip().rstrip(";") + for group in _iter_value_groups(vals_part): + vals = [_parse_value(v) for v in _split_top_level(group)] + if len(vals) != len(cols): + continue # malformed row; skip + yield dict(zip(cols, vals)) + + +# ============= EOF ============================================= From 8bc427f708173a801f1554b66a9da2d2c21c8de0 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 6 Jun 2026 22:42:09 -0600 Subject: [PATCH 006/160] refactor(transfers): standalone transfer_geothermal; deprecate transfer.py Move the NM_Wells (geothermal) orchestration out of transfers/transfer.py into a new standalone transfers/transfer_geothermal.py. Revert all NM_Wells wiring from transfer.py and mark that module deprecated (module docstring + DeprecationWarning in transfer_all) so new migrations get their own orchestrator. transfer_geothermal.py runs the reference->lexicon load (TRANSFER_GEOTHERMAL_REFERENCE) and the NMW_* mirror load (TRANSFER_NMW_MIRROR); both default on. Run: python -m transfers.transfer_geothermal. Co-Authored-By: Claude Opus 4.8 --- .env.example | 6 +- transfers/transfer.py | 26 ++++---- transfers/transfer_geothermal.py | 105 +++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 transfers/transfer_geothermal.py diff --git a/.env.example b/.env.example index 8895581b0..23ad212e5 100644 --- a/.env.example +++ b/.env.example @@ -40,8 +40,10 @@ TRANSFER_NGWMN_VIEWS=True TRANSFER_WATERLEVELS_PRESSURE_DAILY=True TRANSFER_WEATHER_DATA=True TRANSFER_MINOR_TRACE_CHEMISTRY=True -# NM_Wells 1:1 staging mirror load (separate source DB; off by default) -TRANSFER_NMW_MIRROR=False +# NM_Wells (geothermal) migration: run `python -m transfers.transfer_geothermal` +# (separate from the deprecated transfers/transfer.py NM_Aquifer driver). +TRANSFER_GEOTHERMAL_REFERENCE=True # load ref_* lookups into the lexicon +TRANSFER_NMW_MIRROR=True # load the NMW_* 1:1 staging mirror # Optional: path to a NM_Wells SQL Server data-dump .sql file (INSERT statements). # When set, the mirror loads from it; otherwise it falls back to CSV exports. # NMW_SQL_DUMP=/path/to/NMWells_data.sql diff --git a/transfers/transfer.py b/transfers/transfer.py index c42910ada..f0ed4314d 100644 --- a/transfers/transfer.py +++ b/transfers/transfer.py @@ -13,8 +13,16 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +"""DEPRECATED: legacy NM_Aquifer -> Ocotillo transfer orchestrator. + +This module (the original AMPAPI / NM_Aquifer migration driver) is deprecated. +Do not add new migrations here. New migrations get their own standalone +orchestrator script; e.g. the NM_Wells geothermal migration lives in +``transfers/transfer_geothermal.py``. +""" import os import time +import warnings from concurrent.futures import ThreadPoolExecutor, as_completed from contextlib import contextmanager from dataclasses import dataclass @@ -55,8 +63,6 @@ from services.env import get_bool_env from transfers.aquifer_system_transfer import transfer_aquifer_systems from transfers.geologic_formation_transfer import transfer_geologic_formations -from transfers.reference_lexicon_transfer import transfer_reference_tables -from transfers.nmw_mirror_transfer import transfer_nmw_mirror from transfers.permissions_transfer import transfer_permissions from transfers.stratigraphy_legacy import StratigraphyLegacyTransferer from transfers.stratigraphy_transfer import transfer_stratigraphy @@ -326,6 +332,12 @@ def _drop_and_rebuild_db() -> None: @timeit def transfer_all(metrics: Metrics) -> list[ProfileArtifact]: + warnings.warn( + "transfers.transfer is deprecated; new migrations get their own " + "orchestrator (e.g. transfers/transfer_geothermal.py).", + DeprecationWarning, + stacklevel=2, + ) message("STARTING TRANSFER", new_line_at_top=False) if get_bool_env("DROP_AND_REBUILD_DB", False): logger.info("Dropping schema and rebuilding database from migrations") @@ -362,12 +374,11 @@ def transfer_all(metrics: Metrics) -> list[ProfileArtifact]: else: message("PHASE 1: FOUNDATIONAL TRANSFERS (PARALLEL)") foundational_tasks = [ - ("ReferenceLexicon", transfer_reference_tables), ("AquiferSystems", transfer_aquifer_systems), ("GeologicFormations", transfer_geologic_formations), ] - with ThreadPoolExecutor(max_workers=len(foundational_tasks)) as executor: + with ThreadPoolExecutor(max_workers=2) as executor: futures = { executor.submit( _execute_foundational_transfer_with_timing, name, func, limit @@ -386,13 +397,6 @@ def transfer_all(metrics: Metrics) -> list[ProfileArtifact]: logger.critical(f"Foundational transfer {name} failed: {e}") raise # Fail fast - foundational transfers must succeed - # NM_Wells 1:1 staging mirror (separate source DB). Off by default so it - # does not run during the standard NM_Aquifer -> Ocotillo transfer. - if get_bool_env("TRANSFER_NMW_MIRROR", False): - message("NM_WELLS 1:1 STAGING MIRROR LOAD") - with session_ctx() as session: - transfer_nmw_mirror(session, limit=limit) - message("TRANSFERRING WELLS") use_parallel_wells = get_bool_env("TRANSFER_PARALLEL_WELLS", True) if use_parallel_wells: diff --git a/transfers/transfer_geothermal.py b/transfers/transfer_geothermal.py new file mode 100644 index 000000000..a9d9d0b47 --- /dev/null +++ b/transfers/transfer_geothermal.py @@ -0,0 +1,105 @@ +# =============================================================================== +# 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. +# =============================================================================== +"""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: + +1. Reference -> lexicon load (``ref_*`` lookups), gated by + ``TRANSFER_GEOTHERMAL_REFERENCE`` (default True). +2. NM_Wells 1:1 staging mirror load into the ``NMW_*`` tables, gated by + ``TRANSFER_NMW_MIRROR`` (default True). Row source is a SQL Server data dump + when ``NMW_SQL_DUMP`` is set, otherwise per-table CSV exports. + +Assumes the schema already exists (run ``alembic upgrade head`` first). Does not +drop/rebuild the database. + +Run: + python -m transfers.transfer_geothermal +Env: + TRANSFER_LIMIT=1000 # rows per table (0/unset = all) + NMW_SQL_DUMP=/path/to/data.sql # optional; else CSV + TRANSFER_GEOTHERMAL_REFERENCE=1 + TRANSFER_NMW_MIRROR=1 +""" + +import os + +from dotenv import load_dotenv + +# Load .env FIRST, before any database imports. Do not override env vars already +# set by the runtime (e.g. Cloud Run jobs). +load_dotenv(override=False) + +# In managed runtimes DB_DRIVER is sometimes omitted while CLOUD_SQL_* are set. +if ( + not (os.getenv("DB_DRIVER") or "").strip() + and (os.getenv("CLOUD_SQL_INSTANCE_NAME") or "").strip() +): + os.environ["DB_DRIVER"] = "cloudsql" + +from db.engine import session_ctx # noqa: E402 +from services.env import get_bool_env # noqa: E402 +from transfers.logger import logger # noqa: E402 +from transfers.nmw_mirror_transfer import transfer_nmw_mirror # noqa: E402 +from transfers.reference_lexicon_transfer import transfer_reference_tables # noqa: E402 + + +def run_geothermal_transfer(limit: int = None) -> dict: + """Run the NM_Wells geothermal staging migration. Returns a summary dict.""" + limit = int(limit if limit is not None else os.getenv("TRANSFER_LIMIT", 0) or 0) + summary: dict = {} + + logger.info("========== NM_WELLS (GEOTHERMAL) MIGRATION ==========") + logger.info("limit=%s", limit or "all") + + if get_bool_env("TRANSFER_GEOTHERMAL_REFERENCE", True): + logger.info("---- Reference tables -> lexicon ----") + with session_ctx() as session: + tables, created, errors = transfer_reference_tables(session, limit=limit) + summary["reference"] = { + "tables": tables, + "terms_created": created, + "errors": len(errors), + } + else: + logger.info("Skipping reference->lexicon (TRANSFER_GEOTHERMAL_REFERENCE=0)") + + if get_bool_env("TRANSFER_NMW_MIRROR", True): + logger.info("---- NM_Wells 1:1 staging mirror ----") + with session_ctx() as session: + tables, inserted, errors = transfer_nmw_mirror(session, limit=limit) + summary["mirror"] = { + "tables": tables, + "rows_inserted": inserted, + "errors": len(errors), + } + else: + logger.info("Skipping NM_Wells mirror (TRANSFER_NMW_MIRROR=0)") + + logger.info("NM_Wells migration complete: %s", summary) + return summary + + +def main() -> None: + run_geothermal_transfer() + + +if __name__ == "__main__": + main() + + +# ============= EOF ============================================= From 6d7e92c2b28a9f3d915d8f275c712484e48b79a5 Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Sun, 7 Jun 2026 04:42:33 +0000 Subject: [PATCH 007/160] Formatting changes --- transfers/transfer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/transfers/transfer.py b/transfers/transfer.py index f0ed4314d..b1cae6ba2 100644 --- a/transfers/transfer.py +++ b/transfers/transfer.py @@ -20,6 +20,7 @@ orchestrator script; e.g. the NM_Wells geothermal migration lives in ``transfers/transfer_geothermal.py``. """ + import os import time import warnings From 83eb17029b15b1f4ce7bfb74bbbdd1dacf865305 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 6 Jun 2026 22:46:01 -0600 Subject: [PATCH 008/160] feat(transfers): ref lexicon loads from the same SQL dump as the mirror reference_lexicon_transfer now selects its row source the same way as nmw_mirror_transfer: a SQL Server data dump when NMW_SQL_DUMP is set (parsed by nmw_sql_dump.iter_table_rows), otherwise per-table CSV. _pick_columns operates on a column-name list and rows are processed as dicts so both sources share one path. Co-Authored-By: Claude Opus 4.8 --- transfers/reference_lexicon_transfer.py | 81 +++++++++++++++++++------ 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/transfers/reference_lexicon_transfer.py b/transfers/reference_lexicon_transfer.py index 6a20461ae..3ae6ecb81 100644 --- a/transfers/reference_lexicon_transfer.py +++ b/transfers/reference_lexicon_transfer.py @@ -25,8 +25,9 @@ term<->category associations are inserted only when missing (no unique constraint exists on that table). -Source CSVs are read with ``transfers.util.read_csv`` (looks in -``transfers/data/nma_csv_cache/
.csv`` then GCS ``nma_csv/
.csv``). +Row source is the same as the mirror loader: a SQL Server data dump when +``NMW_SQL_DUMP`` is set (parsed by ``transfers.nmw_sql_dump.iter_table_rows``), +otherwise per-table CSV exports via ``transfers.util.read_csv``. NOTE(columns): the ref tables' actual column names are not in the workbook, so term/definition columns are AUTO-DETECTED per table (see ``_pick_columns``). If @@ -38,6 +39,8 @@ to ``REFERENCE_TABLE_SPECS`` once their CSVs are available. """ +import itertools +import os from dataclasses import dataclass from typing import Optional @@ -52,8 +55,12 @@ LexiconTermCategoryAssociation, ) from transfers.logger import logger +from transfers.nmw_sql_dump import iter_table_rows from transfers.util import read_csv +# Same source selector as the mirror loader: a SQL Server data dump when +# NMW_SQL_DUMP is set, otherwise per-table CSV exports. +_SQL_DUMP_ENV = "NMW_SQL_DUMP" # lexicon_term.term (and its FK targets) is String(100). _TERM_MAX_LEN = 100 @@ -158,15 +165,15 @@ def _spec(table: str) -> RefTableSpec: ] -def _pick_columns(df: pd.DataFrame, spec: RefTableSpec) -> tuple[str, str]: +def _pick_columns(columns: list[str], spec: RefTableSpec) -> tuple[str, str]: """Resolve (term_col, definition_col) for a ref table. - Honors explicit overrides on the spec, else auto-detects from the header - using name hints, ignoring meta columns (OBJECTID, GlobalID, ...). + Honors explicit overrides on the spec, else auto-detects from the column + names using name hints, ignoring meta columns (OBJECTID, GlobalID, ...). """ - cols = [c for c in df.columns if str(c).strip().lower() not in _META_COLS] + cols = [c for c in columns if str(c).strip().lower() not in _META_COLS] if not cols: - cols = list(df.columns) + cols = list(columns) low = {c: str(c).strip().lower() for c in cols} term_col = spec.term_col @@ -213,29 +220,53 @@ def _get_or_create_category(session: Session, spec: RefTableSpec) -> int: ).scalar_one() +def _iter_source_rows(table: str, limit: int = 0): + """Yield raw ``{column: value}`` dicts for a ref table. + + SQL dump when NMW_SQL_DUMP is set (same source as the mirror loader), + otherwise per-table CSV. Mirrors transfers.nmw_mirror_transfer._row_source. + """ + dump = os.getenv(_SQL_DUMP_ENV) + if dump: + it = iter_table_rows(dump, table) + else: + df = read_csv(table) + it = (rec for rec in df.to_dict("records")) + if limit and limit > 0: + it = itertools.islice(it, limit) + return it + + def _transfer_one(session: Session, spec: RefTableSpec, limit: int = 0) -> dict: """Load a single ref table into the lexicon. Returns a stats dict.""" try: - df = read_csv(spec.source_table) - except Exception as e: # noqa: BLE001 - missing CSV / GCS miss should not abort - logger.warning("Skipping %s (could not read CSV): %s", spec.source_table, e) + rows = list(_iter_source_rows(spec.source_table, limit)) + except Exception as e: # noqa: BLE001 - missing source should not abort the run + logger.warning("Skipping %s (could not read source): %s", spec.source_table, e) return {"table": spec.source_table, "skipped": True, "reason": str(e)} - if limit and limit > 0: - df = df.head(limit) - - if df.empty or not list(df.columns): + if not rows: logger.warning("Skipping %s (empty)", spec.source_table) return {"table": spec.source_table, "skipped": True, "reason": "empty"} - term_col, def_col = _pick_columns(df, spec) + # Column names from the union of row keys (CSV rows and SSMS INSERTs are + # column-consistent, but be defensive). + columns: list[str] = [] + seen = set() + for rec in rows: + for k in rec: + if k not in seen: + seen.add(k) + columns.append(k) + + term_col, def_col = _pick_columns(columns, spec) logger.info( "%s -> category=%s term_col=%s definition_col=%s (%d rows)", spec.source_table, spec.category, term_col, def_col, - len(df), + len(rows), ) category_id = _get_or_create_category(session, spec) @@ -243,14 +274,14 @@ def _transfer_one(session: Session, spec: RefTableSpec, limit: int = 0) -> dict: # Build unique (term -> definition) map, dropping empties and overlong terms. term_defs: dict[str, str] = {} truncated = 0 - for row in df.itertuples(index=False): - term = _clean(getattr(row, term_col, None)) + for rec in rows: + term = _clean(rec.get(term_col)) if term is None: continue if len(term) > _TERM_MAX_LEN: term = term[:_TERM_MAX_LEN] truncated += 1 - definition = _clean(getattr(row, def_col, None)) or term + definition = _clean(rec.get(def_col)) or term term_defs.setdefault(term, definition) if not term_defs: @@ -316,7 +347,7 @@ def _transfer_one(session: Session, spec: RefTableSpec, limit: int = 0) -> dict: return { "table": spec.source_table, "skipped": False, - "rows": len(df), + "rows": len(rows), "terms": len(term_defs), "created_terms": len(new_rows), "linked": len(assoc_rows), @@ -331,6 +362,16 @@ def transfer_reference_tables(session: Session, limit: int = None) -> tuple: ``(num_tables, total_created_terms, errors)``. """ limit = int(limit or 0) + dump = os.getenv(_SQL_DUMP_ENV) + if dump: + if not os.path.exists(dump): + raise FileNotFoundError(f"{_SQL_DUMP_ENV} set but file not found: {dump}") + logger.info("Reference lexicon source: SQL dump %s", dump) + else: + logger.info( + "Reference lexicon source: CSV exports (set %s for a dump)", _SQL_DUMP_ENV + ) + results = [] errors = [] for spec in REFERENCE_TABLE_SPECS: From f4610982204cc323870c15679db5f725258d2db2 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 7 Jun 2026 00:22:05 -0600 Subject: [PATCH 009/160] docs(db): flag NMW_* attributes that become lexicon terms/enums Add LEXICON_REF_BY_COLUMN mapping every coded mirror attribute to its ref_* source table (which reference_lexicon_transfer loads as a lexicon category whose rows become terms). These 40 attributes will become lexicon_term FKs / enums in the Phase-2 transform. Add LEXICON_CANDIDATES_NO_REF for 8 coded columns that have no ref_* table and will need a new category/enum (DrillFluid, TestType, Operation, etc.). Validated: every column + ref table exists. Co-Authored-By: Claude Opus 4.8 --- db/nmw_legacy.py | 97 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/db/nmw_legacy.py b/db/nmw_legacy.py index 9d65c7226..689a704df 100644 --- a/db/nmw_legacy.py +++ b/db/nmw_legacy.py @@ -100,6 +100,20 @@ on OBJECTID and GlobalID; OBJECTID (identity, never NULL) is used. - Geothermal/DST: declared PKs where present (BHTGUID, IntrvlGUID, DSTGUID, DSTInterval); the rest are heaps keyed on the OBJECTID identity column. + +LEXICON FLAGGING (Phase 2) +-------------------------- +Every ``ref_*`` table is loaded as a ``LexiconCategory`` whose rows become +``LexiconTerm``s (see transfers/reference_lexicon_transfer.py). The mirror +columns that hold those coded values will, in the Phase-2 Ocotillo model, +become ``lexicon_term`` foreign keys / enums. + +``LEXICON_REF_BY_COLUMN`` below flags every such attribute, mapping +``{tablename: {source_column: ref_source_table}}``. The lexicon *category* for +each ref table is assigned by ``transfers/reference_lexicon_transfer.py`` (one +category per ref table), so this map records the stable ref-table name rather +than the derived category string. ``LEXICON_CANDIDATES_NO_REF`` lists coded +columns that have no ``ref_*`` table and will need a NEW category / enum. """ from sqlalchemy import ( @@ -115,6 +129,89 @@ from db.base import Base +# Attributes that will become lexicon_term FKs / enums in the Phase-2 transform. +# {tablename: {source_column: ref_source_table}}. The lexicon category per ref +# table is assigned by transfers/reference_lexicon_transfer.py. +LEXICON_REF_BY_COLUMN: dict[str, dict[str, str]] = { + "NMW_WellLocations": { + "UnitLetter": "ref_unit_letters", + "State": "ref_states", + "County": "ref_county", + "Basin": "ref_basins", + "SourceDatum": "ref_coordinate_datum", + "SourceUnits": "ref_xy_units", + "LocAccType": "ref_coordinate_accuracy", + "LocAccMeas": "ref_coordinate_method", + }, + "NMW_WellHeaders": { + "WellClass": "ref_well_class", + "WellType": "ref_well_types", + "WellOrient": "ref_well_orientations", + "CurStatus": "ref_well_status", + }, + "NMW_WellRecords": { + "RecrdClass": "ref_well_record_class", + }, + "NMW_WellZDatum": { + "DepthDatum": "ref_ground_levels", + "DepthUnits": "ref_unit_depths", + "Z_datum": "ref_altitude_datums", + "Z_units": "ref_unit_depths", + "ElevSource": "ref_altitude_methods", + }, + "NMW_WellSamples": { + "SamplClass": "ref_sample_class", + "SampleType": "ref_sample_types", + "SmpDpUnt": "ref_unit_depths", + }, + "NMW_GtBhtHeaders": { + "BoreUnits": "ref_length_units", + "TempUnit": "ref_unit_temps", + }, + "NMW_GtBhtData": { + "TempUnit": "ref_unit_temps", + }, + "NMW_GtConductivity": { + "CnductUnit": "ref_unit_conductivity", + }, + "NMW_GtHeatFlow": { + "Kpr_unit": "ref_unit_conductivity", + "Q_unit": "ref_unit_heat_flow", + }, + "NMW_GtSumHeatFlow": { + "LithClass": "ref_lith_class", + "UnitBasis": "ref_unit_basis", + "DepthUnit": "ref_unit_depths", + "GradUnit": "ref_unit_gradients", + "SampleType": "ref_sample_types", + "TCondUnit": "ref_unit_conductivity", + "HtFlowUnit": "ref_unit_heat_flow", + }, + "NMW_GtTempDepths": { + "TempUnit": "ref_unit_temps", + }, + "NMW_WsDstHeaders": { + "PressUnits": "ref_pres_units", + "TempUnit": "ref_unit_temps", + "PipeDiaUnt": "ref_length_units", + "PipeLenUnt": "ref_length_units", + "ChokeSizUn": "ref_length_units", + }, +} + +# Coded/categorical columns with NO existing ref_* table; Phase 2 must create a +# new lexicon category or enum for these. {tablename: [source_column, ...]}. +LEXICON_CANDIDATES_NO_REF: dict[str, list[str]] = { + "NMW_GtBhtHeaders": ["DrillFluid"], + "NMW_GtHeatFlow": ["Ka_unit"], + "NMW_GtSumHeatFlow": ["Quality"], + "NMW_WsDstHeaders": ["TestType"], + "NMW_WsDstIntervals": ["Status"], + "NMW_WsDstFlowHistory": ["Operation", "RecovType"], + "NMW_WsDstPressure": ["DSTFluid"], +} + + class NMW_WellLocations(Base): """1:1 mirror of NM_Wells ``tbl_well_locations`` (Main / Migrate First). From cff5d52c81b20f6bf4a55505c0e62d91a5d1851c Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Sun, 7 Jun 2026 06:22:26 +0000 Subject: [PATCH 010/160] Formatting changes --- db/nmw_legacy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/db/nmw_legacy.py b/db/nmw_legacy.py index 689a704df..4c53c32c9 100644 --- a/db/nmw_legacy.py +++ b/db/nmw_legacy.py @@ -128,7 +128,6 @@ from db.base import Base - # Attributes that will become lexicon_term FKs / enums in the Phase-2 transform. # {tablename: {source_column: ref_source_table}}. The lexicon category per ref # table is assigned by transfers/reference_lexicon_transfer.py. From a63179e7a28e56ec616ed0cc3397536c086cb2ff Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 7 Jun 2026 00:24:30 -0600 Subject: [PATCH 011/160] chore(transfers): clean up _spec category derivation Remove the dead `category = table[4:]` line and fix the stale docstring; the category is nmw_
(e.g. nmw_ref_states). Co-Authored-By: Claude Opus 4.8 --- transfers/reference_lexicon_transfer.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/transfers/reference_lexicon_transfer.py b/transfers/reference_lexicon_transfer.py index 3ae6ecb81..bfd391e21 100644 --- a/transfers/reference_lexicon_transfer.py +++ b/transfers/reference_lexicon_transfer.py @@ -97,11 +97,10 @@ class RefTableSpec: def _spec(table: str) -> RefTableSpec: - """Build a spec with category = table name minus the ``ref_`` prefix.""" - category = table[4:] if table.startswith("ref_") else table + """Build a spec with category ``nmw_
`` (e.g. ``nmw_ref_states``).""" return RefTableSpec( source_table=table, - category=category, + category=f"nmw_{table}", description=f"Imported from NM_Wells {table}", ) From 64e4fdfb6a23213bb4b85866878a3194478e60c4 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 7 Jun 2026 00:39:05 -0600 Subject: [PATCH 012/160] feat(alembic): add geothermal OGC views (BHT + temperature-depth profile) Two pygeoapi point layers over the NMW_* staging mirror, geometry from NMW_WellLocations Lat/Long_dd83: - ogc_geothermal_wells_bht: one feature per geothermal well with bottom-hole temperature data (NMW_GtBhtData), aggregate BHT stats. - ogc_geothermal_wells_temperature_profile: one feature per geothermal well with a downhole temperature-vs-depth series (NMW_GtTempDepths) as an ordered JSON array. Wells link via gt_*.SamplSetID -> NMW_WellSamples -> NMW_WellRecords -> NMW_WellLocations. Guards required tables; drops views on downgrade. Co-Authored-By: Claude Opus 4.8 --- .../w9x0y1z2a3b4_add_geothermal_ogc_views.py | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 alembic/versions/w9x0y1z2a3b4_add_geothermal_ogc_views.py diff --git a/alembic/versions/w9x0y1z2a3b4_add_geothermal_ogc_views.py b/alembic/versions/w9x0y1z2a3b4_add_geothermal_ogc_views.py new file mode 100644 index 000000000..5749ce20a --- /dev/null +++ b/alembic/versions/w9x0y1z2a3b4_add_geothermal_ogc_views.py @@ -0,0 +1,154 @@ +"""add geothermal OGC views (bottom-hole temps + temperature-depth profile) + +Revision ID: w9x0y1z2a3b4 +Revises: v8w9x0y1z2a3 +Create Date: 2026-06-07 00:00:00.000000 + +Two pygeoapi point layers over the NM_Wells staging mirror (db/nmw_legacy.py): + + ogc_geothermal_wells_bht + One feature per geothermal well that has bottom-hole-temperature data + (NMW_GtBhtData), with aggregate BHT stats. + + ogc_geothermal_wells_temperature_profile + One feature per geothermal well that has a downhole temperature-vs-depth + series (NMW_GtTempDepths), with the ordered series as a JSON array. + +Well geometry is built from NMW_WellLocations Lat/Long_dd83 (WGS84). Geothermal +data links to a well via: + gt_*.SamplSetID -> NMW_WellSamples.SamplSetID + NMW_WellSamples.RecrdsetID -> NMW_WellRecords.RecrdSetID + NMW_WellRecords.WellDataID -> NMW_WellLocations/Headers.WellDataID +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "w9x0y1z2a3b4" +down_revision: Union[str, Sequence[str], None] = "v8w9x0y1z2a3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_BHT_VIEW = "ogc_geothermal_wells_bht" +_PROFILE_VIEW = "ogc_geothermal_wells_temperature_profile" + +_REQUIRED_TABLES = ( + "NMW_WellLocations", + "NMW_WellHeaders", + "NMW_WellRecords", + "NMW_WellSamples", + "NMW_GtBhtData", + "NMW_GtBhtHeaders", + "NMW_GtTempDepths", +) + + +def _create_bht_view() -> str: + return """ + CREATE VIEW ogc_geothermal_wells_bht AS + SELECT + r."WellDataID" 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 "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + WHERE loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL + GROUP BY + r."WellDataID", + loc."Lat_dd83", + loc."Long_dd83", + hdr."CurWellNam", + hdr."API", + hdr."TotalDepth" + """ + + +def _create_profile_view() -> str: + return """ + CREATE VIEW ogc_geothermal_wells_temperature_profile AS + SELECT + r."WellDataID" 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 "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + WHERE loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL + AND 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" + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing = set(inspector.get_table_names(schema="public")) + missing = [t for t in _REQUIRED_TABLES if t not in existing] + if missing: + raise RuntimeError( + "Cannot create geothermal OGC views. Missing required tables: " + + ", ".join(missing) + ) + + op.execute(text(f"DROP VIEW IF EXISTS {_BHT_VIEW}")) + op.execute(text(_create_bht_view())) + op.execute( + text( + f"COMMENT ON VIEW {_BHT_VIEW} IS " + "'Geothermal wells with bottom-hole-temperature data (pygeoapi).'" + ) + ) + + op.execute(text(f"DROP VIEW IF EXISTS {_PROFILE_VIEW}")) + op.execute(text(_create_profile_view())) + op.execute( + text( + f"COMMENT ON VIEW {_PROFILE_VIEW} IS " + "'Geothermal wells with downhole temperature-vs-depth series " + "(pygeoapi).'" + ) + ) + + +def downgrade() -> None: + op.execute(text(f"DROP VIEW IF EXISTS {_PROFILE_VIEW}")) + op.execute(text(f"DROP VIEW IF EXISTS {_BHT_VIEW}")) From 9b8037affe6876a16631ea5d38cb8a25dadedd3c Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 7 Jun 2026 00:46:03 -0600 Subject: [PATCH 013/160] feat(alembic): update comments for geothermal OGC views --- alembic/versions/w9x0y1z2a3b4_add_geothermal_ogc_views.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/alembic/versions/w9x0y1z2a3b4_add_geothermal_ogc_views.py b/alembic/versions/w9x0y1z2a3b4_add_geothermal_ogc_views.py index 5749ce20a..832f295ec 100644 --- a/alembic/versions/w9x0y1z2a3b4_add_geothermal_ogc_views.py +++ b/alembic/versions/w9x0y1z2a3b4_add_geothermal_ogc_views.py @@ -4,7 +4,7 @@ Revises: v8w9x0y1z2a3 Create Date: 2026-06-07 00:00:00.000000 -Two pygeoapi point layers over the NM_Wells staging mirror (db/nmw_legacy.py): +Two point layers over the NM_Wells staging mirror (db/nmw_legacy.py): ogc_geothermal_wells_bht One feature per geothermal well that has bottom-hole-temperature data @@ -134,7 +134,7 @@ def upgrade() -> None: op.execute( text( f"COMMENT ON VIEW {_BHT_VIEW} IS " - "'Geothermal wells with bottom-hole-temperature data (pygeoapi).'" + "'Geothermal wells with bottom-hole-temperature data.'" ) ) @@ -143,8 +143,7 @@ def upgrade() -> None: op.execute( text( f"COMMENT ON VIEW {_PROFILE_VIEW} IS " - "'Geothermal wells with downhole temperature-vs-depth series " - "(pygeoapi).'" + "'Geothermal wells with downhole temperature-vs-depth series.'" ) ) From 24a199c09f282bac4bca1285671b611d2989e357 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 7 Jun 2026 00:54:04 -0600 Subject: [PATCH 014/160] perf(alembic): materialize geothermal temperature-profile OGC view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The temperature-vs-depth profile view scans/groups NMW_GtTempDepths (~370k source rows) and builds a per-well JSON series — too heavy to recompute per pygeoapi request. Convert it to a MATERIALIZED view with a unique index on well_data_id (enables REFRESH CONCURRENTLY) and a GiST index on geom. The BHT view stays a regular view (small source). REFRESH after a data reload. Co-Authored-By: Claude Opus 4.8 --- .../w9x0y1z2a3b4_add_geothermal_ogc_views.py | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/alembic/versions/w9x0y1z2a3b4_add_geothermal_ogc_views.py b/alembic/versions/w9x0y1z2a3b4_add_geothermal_ogc_views.py index 832f295ec..6e89bff9b 100644 --- a/alembic/versions/w9x0y1z2a3b4_add_geothermal_ogc_views.py +++ b/alembic/versions/w9x0y1z2a3b4_add_geothermal_ogc_views.py @@ -10,9 +10,11 @@ One feature per geothermal well that has bottom-hole-temperature data (NMW_GtBhtData), with aggregate BHT stats. - ogc_geothermal_wells_temperature_profile + ogc_geothermal_wells_temperature_profile (MATERIALIZED) One feature per geothermal well that has a downhole temperature-vs-depth - series (NMW_GtTempDepths), with the ordered series as a JSON array. + series (NMW_GtTempDepths, ~370k source rows), with the ordered series as + a JSON array. Materialized + indexed (unique well_data_id, GiST geom); + REFRESH MATERIALIZED VIEW after a data reload. Well geometry is built from NMW_WellLocations Lat/Long_dd83 (WGS84). Geothermal data links to a well via: @@ -81,8 +83,12 @@ def _create_bht_view() -> str: def _create_profile_view() -> str: + # Materialized: the source NMW_GtTempDepths is large (~370k source rows) and + # this groups + builds a JSON series per well, too heavy to recompute per + # pygeoapi request. Staging data loads once, so staleness is a non-issue; + # REFRESH MATERIALIZED VIEW after a reload. return """ - CREATE VIEW ogc_geothermal_wells_temperature_profile AS + CREATE MATERIALIZED VIEW ogc_geothermal_wells_temperature_profile AS SELECT r."WellDataID" AS well_data_id, hdr."CurWellNam" AS well_name, @@ -138,16 +144,30 @@ def upgrade() -> None: ) ) + op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {_PROFILE_VIEW}")) op.execute(text(f"DROP VIEW IF EXISTS {_PROFILE_VIEW}")) op.execute(text(_create_profile_view())) op.execute( text( - f"COMMENT ON VIEW {_PROFILE_VIEW} IS " + f"COMMENT ON MATERIALIZED VIEW {_PROFILE_VIEW} IS " "'Geothermal wells with downhole temperature-vs-depth series.'" ) ) + # Unique index on the feature id enables REFRESH ... CONCURRENTLY; GiST on + # the geometry for fast pygeoapi bbox queries. + op.execute( + text( + f"CREATE UNIQUE INDEX ux_{_PROFILE_VIEW}_well_data_id " + f"ON {_PROFILE_VIEW} (well_data_id)" + ) + ) + op.execute( + text( + f"CREATE INDEX ix_{_PROFILE_VIEW}_geom ON {_PROFILE_VIEW} USING gist (geom)" + ) + ) def downgrade() -> None: - op.execute(text(f"DROP VIEW IF EXISTS {_PROFILE_VIEW}")) + op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {_PROFILE_VIEW}")) op.execute(text(f"DROP VIEW IF EXISTS {_BHT_VIEW}")) From 158d97e11d892f44436859ad2e51984373cd9845 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 7 Jun 2026 00:55:33 -0600 Subject: [PATCH 015/160] feat(alembic): add geothermal heat-flow OGC view pygeoapi point layer ogc_geothermal_wells_heat_flow: one feature per geothermal well with summary heat-flow determinations (NMW_GtSumHeatFlow) - aggregate heat flow, thermal gradient, thermal conductivity and quality. Geometry from NMW_WellLocations; linked via NMW_GtSumHeatFlow.RecrdSetID -> NMW_WellRecords. Co-Authored-By: Claude Opus 4.8 --- ...3b4c5_add_geothermal_heat_flow_ogc_view.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 alembic/versions/x0y1z2a3b4c5_add_geothermal_heat_flow_ogc_view.py diff --git a/alembic/versions/x0y1z2a3b4c5_add_geothermal_heat_flow_ogc_view.py b/alembic/versions/x0y1z2a3b4c5_add_geothermal_heat_flow_ogc_view.py new file mode 100644 index 000000000..4dbf8c7c4 --- /dev/null +++ b/alembic/versions/x0y1z2a3b4c5_add_geothermal_heat_flow_ogc_view.py @@ -0,0 +1,92 @@ +"""add geothermal heat-flow OGC view + +Revision ID: x0y1z2a3b4c5 +Revises: w9x0y1z2a3b4 +Create Date: 2026-06-07 00:00:01.000000 + +pygeoapi point layer of geothermal wells with summary heat-flow determinations +(NMW_GtSumHeatFlow), one feature per well with aggregate heat-flow / gradient / +conductivity stats. Geometry from NMW_WellLocations Lat/Long_dd83. + +Link: NMW_GtSumHeatFlow.RecrdSetID -> NMW_WellRecords.RecrdSetID -> +NMW_WellLocations/Headers.WellDataID. +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "x0y1z2a3b4c5" +down_revision: Union[str, Sequence[str], None] = "w9x0y1z2a3b4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_VIEW = "ogc_geothermal_wells_heat_flow" + +_REQUIRED_TABLES = ( + "NMW_WellLocations", + "NMW_WellHeaders", + "NMW_WellRecords", + "NMW_GtSumHeatFlow", +) + + +def _create_view() -> str: + return """ + CREATE VIEW ogc_geothermal_wells_heat_flow AS + SELECT + r."WellDataID" AS well_data_id, + hdr."CurWellNam" AS well_name, + hdr."API" AS api, + count(shf.*) AS heat_flow_count, + max(shf."HeatFlow") AS max_heat_flow, + avg(shf."HeatFlow") AS avg_heat_flow, + max(shf."HtFlowUnit") AS heat_flow_unit, + max(shf."ThermlGrad") AS max_thermal_gradient, + max(shf."GradUnit") AS gradient_unit, + max(shf."ThermlCond") AS max_thermal_conductivity, + max(shf."TCondUnit") AS conductivity_unit, + max(shf."Quality") AS quality, + ST_SetSRID( + ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326 + ) AS geom + FROM "NMW_GtSumHeatFlow" AS shf + JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = shf."RecrdSetID" + JOIN "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + WHERE loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL + GROUP BY + r."WellDataID", + loc."Lat_dd83", + loc."Long_dd83", + hdr."CurWellNam", + hdr."API" + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing = set(inspector.get_table_names(schema="public")) + missing = [t for t in _REQUIRED_TABLES if t not in existing] + if missing: + raise RuntimeError( + "Cannot create geothermal heat-flow OGC view. Missing required " + "tables: " + ", ".join(missing) + ) + + op.execute(text(f"DROP VIEW IF EXISTS {_VIEW}")) + op.execute(text(_create_view())) + op.execute( + text( + f"COMMENT ON VIEW {_VIEW} IS " + "'Geothermal wells with summary heat-flow determinations (pygeoapi).'" + ) + ) + + +def downgrade() -> None: + op.execute(text(f"DROP VIEW IF EXISTS {_VIEW}")) From a92cba95ad527c7b0971f2d1b11811c5a2c85b2d Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 7 Jun 2026 00:58:39 -0600 Subject: [PATCH 016/160] feat(alembic): add geothermal per-interval heat-flow OGC view pygeoapi point layer ogc_geothermal_wells_interval_heat_flow from NMW_GtHeatFlow (per-interval values: Q heat flow, gradient, Kpr conductivity, Ka diffusivity), one feature per well. Distinct from ogc_geothermal_wells_heat_flow (summary, NMW_GtSumHeatFlow). Linked via IntrvlGUID -> NMW_WsIntervals -> NMW_WellSamples -> NMW_WellRecords -> NMW_WellLocations. Co-Authored-By: Claude Opus 4.8 --- ..._geothermal_interval_heat_flow_ogc_view.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 alembic/versions/y1z2a3b4c5d6_add_geothermal_interval_heat_flow_ogc_view.py diff --git a/alembic/versions/y1z2a3b4c5d6_add_geothermal_interval_heat_flow_ogc_view.py b/alembic/versions/y1z2a3b4c5d6_add_geothermal_interval_heat_flow_ogc_view.py new file mode 100644 index 000000000..7b5b5163a --- /dev/null +++ b/alembic/versions/y1z2a3b4c5d6_add_geothermal_interval_heat_flow_ogc_view.py @@ -0,0 +1,98 @@ +"""add geothermal per-interval heat-flow OGC view + +Revision ID: y1z2a3b4c5d6 +Revises: x0y1z2a3b4c5 +Create Date: 2026-06-07 00:00:02.000000 + +pygeoapi point layer of geothermal wells with per-interval heat-flow values +(NMW_GtHeatFlow), one feature per well with aggregate heat-flow / gradient / +conductivity / diffusivity stats. Distinct from ogc_geothermal_wells_heat_flow, +which is the summary (NMW_GtSumHeatFlow) layer. + +Link: NMW_GtHeatFlow.IntrvlGUID -> NMW_WsIntervals.IntrvlGUID -> +NMW_WellSamples.SamplSetID -> NMW_WellRecords.RecrdSetID -> +NMW_WellLocations/Headers.WellDataID. +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "y1z2a3b4c5d6" +down_revision: Union[str, Sequence[str], None] = "x0y1z2a3b4c5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_VIEW = "ogc_geothermal_wells_interval_heat_flow" + +_REQUIRED_TABLES = ( + "NMW_WellLocations", + "NMW_WellHeaders", + "NMW_WellRecords", + "NMW_WellSamples", + "NMW_WsIntervals", + "NMW_GtHeatFlow", +) + + +def _create_view() -> str: + return """ + CREATE VIEW ogc_geothermal_wells_interval_heat_flow AS + SELECT + r."WellDataID" AS well_data_id, + hdr."CurWellNam" AS well_name, + hdr."API" AS api, + count(hf.*) AS interval_count, + max(hf."Q") AS max_heat_flow, + avg(hf."Q") AS avg_heat_flow, + max(hf."Q_unit") AS heat_flow_unit, + max(hf."Gradient") AS max_gradient, + max(hf."Kpr") AS max_thermal_conductivity, + max(hf."Kpr_unit") AS conductivity_unit, + max(hf."Ka") AS max_diffusivity, + max(hf."Ka_unit") AS diffusivity_unit, + ST_SetSRID( + ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326 + ) AS geom + FROM "NMW_GtHeatFlow" AS hf + JOIN "NMW_WsIntervals" AS i ON i."IntrvlGUID" = hf."IntrvlGUID" + JOIN "NMW_WellSamples" AS s ON s."SamplSetID" = i."SamplSetID" + JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = s."RecrdsetID" + JOIN "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + WHERE loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL + GROUP BY + r."WellDataID", + loc."Lat_dd83", + loc."Long_dd83", + hdr."CurWellNam", + hdr."API" + """ + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing = set(inspector.get_table_names(schema="public")) + missing = [t for t in _REQUIRED_TABLES if t not in existing] + if missing: + raise RuntimeError( + "Cannot create geothermal interval heat-flow OGC view. Missing " + "required tables: " + ", ".join(missing) + ) + + op.execute(text(f"DROP VIEW IF EXISTS {_VIEW}")) + op.execute(text(_create_view())) + op.execute( + text( + f"COMMENT ON VIEW {_VIEW} IS " + "'Geothermal wells with per-interval heat-flow values (pygeoapi).'" + ) + ) + + +def downgrade() -> None: + op.execute(text(f"DROP VIEW IF EXISTS {_VIEW}")) From a4a5952da259b01ab1395f95ca38343f6e527abb Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 7 Jun 2026 01:04:26 -0600 Subject: [PATCH 017/160] feat(alembic): heat-flow OGC views return per-feature measurement series - Rename ogc_geothermal_wells_heat_flow -> ogc_geothermal_wells_summary_heat_flow. - Add a `measurements` JSON series to both heat-flow views: one element per determination/interval (depth range, heat flow, gradient, conductivity, etc.), ordered by depth, alongside the existing per-well aggregates. Co-Authored-By: Claude Opus 4.8 --- ...3b4c5_add_geothermal_heat_flow_ogc_view.py | 27 +++++++++++++++---- ..._geothermal_interval_heat_flow_ogc_view.py | 20 +++++++++++--- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/alembic/versions/x0y1z2a3b4c5_add_geothermal_heat_flow_ogc_view.py b/alembic/versions/x0y1z2a3b4c5_add_geothermal_heat_flow_ogc_view.py index 4dbf8c7c4..5422102ae 100644 --- a/alembic/versions/x0y1z2a3b4c5_add_geothermal_heat_flow_ogc_view.py +++ b/alembic/versions/x0y1z2a3b4c5_add_geothermal_heat_flow_ogc_view.py @@ -4,9 +4,10 @@ Revises: w9x0y1z2a3b4 Create Date: 2026-06-07 00:00:01.000000 -pygeoapi point layer of geothermal wells with summary heat-flow determinations -(NMW_GtSumHeatFlow), one feature per well with aggregate heat-flow / gradient / -conductivity stats. Geometry from NMW_WellLocations Lat/Long_dd83. +pygeoapi point layer ogc_geothermal_wells_summary_heat_flow: geothermal wells +with summary heat-flow determinations (NMW_GtSumHeatFlow), one feature per well +with aggregate stats plus a `measurements` JSON series (one element per +determination, ordered by depth). Geometry from NMW_WellLocations Lat/Long_dd83. Link: NMW_GtSumHeatFlow.RecrdSetID -> NMW_WellRecords.RecrdSetID -> NMW_WellLocations/Headers.WellDataID. @@ -23,7 +24,7 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None -_VIEW = "ogc_geothermal_wells_heat_flow" +_VIEW = "ogc_geothermal_wells_summary_heat_flow" _REQUIRED_TABLES = ( "NMW_WellLocations", @@ -35,7 +36,7 @@ def _create_view() -> str: return """ - CREATE VIEW ogc_geothermal_wells_heat_flow AS + CREATE VIEW ogc_geothermal_wells_summary_heat_flow AS SELECT r."WellDataID" AS well_data_id, hdr."CurWellNam" AS well_name, @@ -49,6 +50,22 @@ def _create_view() -> str: max(shf."ThermlCond") AS max_thermal_conductivity, max(shf."TCondUnit") AS conductivity_unit, max(shf."Quality") AS quality, + json_agg( + json_build_object( + 'from_depth', shf."FromDepth", + 'to_depth', shf."ToDepth", + 'depth_unit', shf."DepthUnit", + 'heat_flow', shf."HeatFlow", + 'heat_flow_error', shf."HtFlowErr", + 'heat_flow_unit', shf."HtFlowUnit", + 'thermal_gradient', shf."ThermlGrad", + 'gradient_unit', shf."GradUnit", + 'thermal_conductivity', shf."ThermlCond", + 'conductivity_unit', shf."TCondUnit", + 'quality', shf."Quality" + ) + ORDER BY shf."FromDepth" + ) AS measurements, ST_SetSRID( ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326 ) AS geom diff --git a/alembic/versions/y1z2a3b4c5d6_add_geothermal_interval_heat_flow_ogc_view.py b/alembic/versions/y1z2a3b4c5d6_add_geothermal_interval_heat_flow_ogc_view.py index 7b5b5163a..12f570183 100644 --- a/alembic/versions/y1z2a3b4c5d6_add_geothermal_interval_heat_flow_ogc_view.py +++ b/alembic/versions/y1z2a3b4c5d6_add_geothermal_interval_heat_flow_ogc_view.py @@ -5,9 +5,9 @@ Create Date: 2026-06-07 00:00:02.000000 pygeoapi point layer of geothermal wells with per-interval heat-flow values -(NMW_GtHeatFlow), one feature per well with aggregate heat-flow / gradient / -conductivity / diffusivity stats. Distinct from ogc_geothermal_wells_heat_flow, -which is the summary (NMW_GtSumHeatFlow) layer. +(NMW_GtHeatFlow), one feature per well with aggregate stats plus a +`measurements` JSON series (one element per interval, ordered by depth). +Distinct from ogc_geothermal_wells_summary_heat_flow (NMW_GtSumHeatFlow). Link: NMW_GtHeatFlow.IntrvlGUID -> NMW_WsIntervals.IntrvlGUID -> NMW_WellSamples.SamplSetID -> NMW_WellRecords.RecrdSetID -> @@ -53,6 +53,20 @@ def _create_view() -> str: max(hf."Kpr_unit") AS conductivity_unit, max(hf."Ka") AS max_diffusivity, max(hf."Ka_unit") AS diffusivity_unit, + json_agg( + json_build_object( + 'from_depth', i."From_Depth", + 'to_depth', i."To_Depth", + 'heat_flow', hf."Q", + 'heat_flow_unit', hf."Q_unit", + 'gradient', hf."Gradient", + 'thermal_conductivity', hf."Kpr", + 'conductivity_unit', hf."Kpr_unit", + 'diffusivity', hf."Ka", + 'diffusivity_unit', hf."Ka_unit" + ) + ORDER BY i."From_Depth" + ) AS measurements, ST_SetSRID( ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326 ) AS geom From 890232801a5076c1a5c9f32b36bf2debf4077c7c Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 7 Jun 2026 21:58:06 -0600 Subject: [PATCH 018/160] feat(transfers): load NM_Wells mirror via sqlparse CSV + Postgres COPY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When NMW_SQL_DUMP is set, the mirror now parses the dump with sqlparse (nmw_sql_dump.write_table_csv) into a CSV per table, then bulk-loads each via Postgres COPY ... FROM STDIN (truncate + COPY; Postgres casts text -> types) — far faster than row-by-row ORM inserts. CSV dir defaults to a temp dir (override NMW_CSV_DIR). The CSV-exports fallback (no dump) keeps the row-insert path. Adds sqlparse dependency. Co-Authored-By: Claude Opus 4.8 --- .env.example | 5 +- pyproject.toml | 1 + transfers/nmw_mirror_transfer.py | 66 ++++++++++++-- transfers/nmw_sql_dump.py | 147 ++++++++++++++++--------------- uv.lock | 13 ++- 5 files changed, 151 insertions(+), 81 deletions(-) diff --git a/.env.example b/.env.example index 23ad212e5..a3dca336f 100644 --- a/.env.example +++ b/.env.example @@ -45,8 +45,11 @@ TRANSFER_MINOR_TRACE_CHEMISTRY=True TRANSFER_GEOTHERMAL_REFERENCE=True # load ref_* lookups into the lexicon TRANSFER_NMW_MIRROR=True # load the NMW_* 1:1 staging mirror # Optional: path to a NM_Wells SQL Server data-dump .sql file (INSERT statements). -# When set, the mirror loads from it; otherwise it falls back to CSV exports. +# When set, the mirror parses it to a CSV per table (sqlparse) and bulk-loads via +# Postgres COPY; otherwise it falls back to CSV exports + row inserts. # NMW_SQL_DUMP=/path/to/NMWells_data.sql +# Optional: dir for the per-table CSVs written from the dump (default: temp dir). +# NMW_CSV_DIR=/path/to/nmw_csv # asset storage GCS_BUCKET_NAME= diff --git a/pyproject.toml b/pyproject.toml index 813650d65..d6a96541b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ dependencies = [ "utm==0.8.1", "uvicorn==0.42.0", "yarl==1.23.0", + "sqlparse>=0.5.5", ] [tool.uv] diff --git a/transfers/nmw_mirror_transfer.py b/transfers/nmw_mirror_transfer.py index 2b84e39c2..9bb0a961f 100644 --- a/transfers/nmw_mirror_transfer.py +++ b/transfers/nmw_mirror_transfer.py @@ -28,11 +28,14 @@ Two row sources, selected at runtime: 1. **SQL Server data dump** (preferred): set ``NMW_SQL_DUMP`` to a ``.sql`` file - containing ``INSERT [dbo].[tbl_*] (...) VALUES (...)`` statements. Rows are - streamed and parsed by ``transfers.nmw_sql_dump.iter_table_rows``. + of ``INSERT [dbo].[tbl_*] (...) VALUES (...)`` statements. Each table is + written to a CSV by ``transfers.nmw_sql_dump.write_table_csv`` (sqlparse) and + bulk-loaded with Postgres ``COPY ... FROM STDIN`` (truncate + COPY; Postgres + casts text -> column types). CSV output dir defaults to a temp dir, override + with ``NMW_CSV_DIR``. 2. **CSV exports** (fallback when ``NMW_SQL_DUMP`` is unset): per-table CSVs read with ``transfers.util.read_csv`` (``transfers/data/nma_csv_cache/
.csv`` - then GCS ``nma_csv/
.csv``). + then GCS ``nma_csv/
.csv``), inserted row-by-row with type coercion. In both cases the source column names are the original SQL Server names (OBJECTID, WellDataID, ...), which match the mirror columns' DB names exactly. @@ -42,11 +45,12 @@ import itertools import os +import tempfile import uuid from dataclasses import dataclass import pandas as pd -from sqlalchemy import DateTime, Float, Integer, LargeBinary, SmallInteger, String +from sqlalchemy import DateTime, Float, Integer, LargeBinary, SmallInteger, String, text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session @@ -71,12 +75,15 @@ NMW_WsIntervals, ) from transfers.logger import logger -from transfers.nmw_sql_dump import iter_table_rows +from transfers.nmw_sql_dump import iter_table_rows, write_table_csv from transfers.util import read_csv # Path to a SQL Server data-dump .sql file. When set, rows are parsed from it; # otherwise the loader falls back to per-table CSV exports. _SQL_DUMP_ENV = "NMW_SQL_DUMP" +# Optional output dir for the per-table CSVs written from the dump (COPY path). +# Defaults to a fresh temp dir. +_CSV_DIR_ENV = "NMW_CSV_DIR" _CHUNK_SIZE = 2000 @@ -181,6 +188,45 @@ def _flush(session: Session, model, rows: list[dict], pk_cols: list[str]) -> int return result.rowcount if result.rowcount and result.rowcount > 0 else 0 +def _copy_csv_into_table( + session: Session, table_name: str, header: list[str], csv_path: str +) -> None: + """Bulk-load a CSV into ``table_name`` via Postgres COPY (pg8000 stream).""" + collist = ", ".join(f'"{c}"' for c in header) + sql = ( + f'COPY "{table_name}" ({collist}) FROM STDIN ' + "WITH (FORMAT CSV, HEADER true, NULL '')" + ) + raw = session.connection().connection # underlying pg8000 DBAPI connection + cursor = raw.cursor() + with open(csv_path, "rb") as f: + cursor.execute(sql, stream=f) + + +def _copy_load_table( + session: Session, spec: MirrorSpec, dump: str, out_dir: str, limit: int = 0 +) -> dict: + """Dump -> per-table CSV (sqlparse) -> COPY into the mirror table.""" + table = spec.model.__table__ + name = spec.source_table + # Load only model columns (rowversion/LargeBinary excluded). COPY relies on + # Postgres to cast text -> column types, so no Python coercion is needed. + columns = [c.name for c in table.columns if not isinstance(c.type, LargeBinary)] + out_csv = os.path.join(out_dir, f"{name}.csv") + + n, header = write_table_csv(dump, name, out_csv, columns=columns, limit=limit) + if n == 0: + logger.warning("Skipping %s (no rows in dump)", name) + return {"table": name, "skipped": True, "reason": "no rows", "source": "sql"} + + # Staging reload: truncate then COPY (no upsert; tables are a 1:1 snapshot). + session.execute(text(f'TRUNCATE TABLE "{table.name}"')) + _copy_csv_into_table(session, table.name, header, out_csv) + session.commit() + logger.info("COPY %s -> %s: %d rows (%s)", name, table.name, n, out_csv) + return {"table": name, "skipped": False, "rows": n, "inserted": n, "source": "sql"} + + def _load_table(session: Session, spec: MirrorSpec, limit: int = 0) -> dict: """Load one source table (SQL dump or CSV) into its mirror. Stats dict.""" table = spec.model.__table__ @@ -251,10 +297,13 @@ def transfer_nmw_mirror(session: Session, limit: int = None) -> tuple: """ limit = int(limit or 0) dump = os.getenv(_SQL_DUMP_ENV) + out_dir = None if dump: if not os.path.exists(dump): raise FileNotFoundError(f"{_SQL_DUMP_ENV} set but file not found: {dump}") - logger.info("NMW mirror source: SQL dump %s", dump) + out_dir = os.getenv(_CSV_DIR_ENV) or tempfile.mkdtemp(prefix="nmw_csv_") + os.makedirs(out_dir, exist_ok=True) + logger.info("NMW mirror source: SQL dump %s -> CSV %s -> COPY", dump, out_dir) else: logger.info("NMW mirror source: CSV exports (set %s for a dump)", _SQL_DUMP_ENV) @@ -262,7 +311,10 @@ def transfer_nmw_mirror(session: Session, limit: int = None) -> tuple: errors = [] for spec in NMW_MIRROR_SPECS: try: - results.append(_load_table(session, spec, limit)) + if dump: + results.append(_copy_load_table(session, spec, dump, out_dir, limit)) + else: + results.append(_load_table(session, spec, limit)) except Exception as e: # noqa: BLE001 - isolate per-table failures logger.critical("NMW mirror load failed for %s: %s", spec.source_table, e) session.rollback() diff --git a/transfers/nmw_sql_dump.py b/transfers/nmw_sql_dump.py index 39637c076..29f72e735 100644 --- a/transfers/nmw_sql_dump.py +++ b/transfers/nmw_sql_dump.py @@ -13,12 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -"""Stream rows out of a SQL Server data-dump ``.sql`` file. +"""Parse a SQL Server data-dump ``.sql`` file into per-table CSVs. -Parses ``INSERT [dbo].[
] () VALUES ()[, () ...]`` -statements (the format produced by SSMS "Generate Scripts -> data" / ``bcp`` -INSERT mode) for one target table at a time, yielding ``{column: value}`` -dicts. Values are decoded to plain Python: +``INSERT [dbo].[
] () VALUES ()[, () ...]`` statements +(SSMS "Generate Scripts -> data" / bcp INSERT mode) are split with ``sqlparse`` +and decoded to plain Python values: NULL -> None N'...' / '...' -> str (doubled '' unescaped) @@ -26,20 +25,21 @@ CAST(expr AS type) -> the inner expr, recursively 0x.... -> None (binary / rowversion; not mirrored) -Type coercion to the target column type happens in nmw_mirror_transfer._coerce, -so this module keeps values loosely typed. - -Streaming: the file is read line by line (constant memory), accumulating across -lines only when a statement's parentheses are unbalanced (strings containing -newlines). The file is scanned once per table. +``iter_table_rows`` yields ``{column: value}`` dicts; ``write_table_csv`` writes +one table to a CSV suitable for a Postgres ``COPY ... FROM`` bulk load (NULL -> +empty field, so load with ``NULL ''``). Encoding is auto-detected from the BOM (SSMS writes UTF-16 LE); falls back to utf-8. """ +import csv +import itertools import re from typing import Iterator, Optional +import sqlparse + def _detect_encoding(path: str) -> str: with open(path, "rb") as f: @@ -150,74 +150,77 @@ def _parse_value(tok: str): _INSERT_RE = re.compile( - r"(?is)INSERT\s+(?:\[dbo\]\.)?\[?(?P
\w+)\]?\s*\((?P.*?)\)\s*VALUES\s*(?P.*)$" + r"(?is)INSERT\s+(?:\[dbo\]\.)?\[?(?P
\w+)\]?\s*" + r"\((?P.*?)\)\s*VALUES\s*(?P.*)$" ) -def _balanced(stmt: str) -> bool: - """True if parens are balanced outside single-quoted strings.""" - depth = 0 - in_quote = False - i = 0 - n = len(stmt) - while i < n: - c = stmt[i] - if in_quote: - if c == "'": - if i + 1 < n and stmt[i + 1] == "'": - i += 2 - continue - in_quote = False - elif c == "'": - in_quote = True - elif c == "(": - depth += 1 - elif c == ")": - depth -= 1 - i += 1 - return depth <= 0 and not in_quote +def _iter_insert_statements(path: str, table: str) -> Iterator[str]: + """Yield raw INSERT statement strings for ``table`` using sqlparse.""" + enc = _detect_encoding(path) + target = table.lower() + with open(path, encoding=enc, errors="ignore") as f: + # parsestream splits the dump into statements lazily. + for statement in sqlparse.parsestream(f): + s = str(statement).strip() + if not s: + continue + low = s.lower() + if "insert" not in low or target not in low: + continue + yield s def iter_table_rows(path: str, table: str) -> Iterator[dict]: """Yield ``{column: value}`` dicts for every INSERT into ``table``.""" - enc = _detect_encoding(path) - target = f"[{table}]".lower() - target_plain = table.lower() - pending: Optional[str] = None - - with open(path, encoding=enc, errors="ignore") as f: - for line in f: - if pending is None: - low = line.lower() - if "insert" not in low: - continue - # cheap table filter before the heavier regex - if ( - target not in low - and f"].[{target_plain}]" not in low - and f" {target_plain} " not in low - ): - if target_plain not in low: - continue - pending = line - else: - pending += line - - if not _balanced(pending): - continue # statement spans more lines - - stmt = pending - pending = None - m = _INSERT_RE.search(stmt) - if not m or m.group("table").lower() != target_plain: - continue - cols = [c.strip().strip("[]") for c in _split_top_level(m.group("cols"))] - vals_part = m.group("vals").strip().rstrip(";") - for group in _iter_value_groups(vals_part): - vals = [_parse_value(v) for v in _split_top_level(group)] - if len(vals) != len(cols): - continue # malformed row; skip - yield dict(zip(cols, vals)) + for stmt in _iter_insert_statements(path, table): + m = _INSERT_RE.search(stmt) + if not m or m.group("table").lower() != table.lower(): + continue + cols = [c.strip().strip("[]") for c in _split_top_level(m.group("cols"))] + vals_part = m.group("vals").strip().rstrip(";") + for group in _iter_value_groups(vals_part): + vals = [_parse_value(v) for v in _split_top_level(group)] + if len(vals) != len(cols): + continue # malformed row; skip + yield dict(zip(cols, vals)) + + +def _csv_cell(value) -> str: + """Render a parsed value for a COPY-friendly CSV (None -> empty field).""" + return "" if value is None else str(value) + + +def write_table_csv( + path: str, + table: str, + out_csv: str, + columns: Optional[list[str]] = None, + limit: int = 0, +) -> tuple[int, list[str]]: + """Write one source table's rows to ``out_csv``. Returns (n_rows, header). + + ``columns`` restricts/orders the output columns (e.g. the target model's + columns); missing source values become empty fields. If omitted, the first + row's keys define the header. None -> empty so Postgres COPY ``NULL ''`` + treats it as NULL. + """ + rows = iter_table_rows(path, table) + if limit and limit > 0: + rows = itertools.islice(rows, limit) + + header: Optional[list[str]] = None + writer = None + n = 0 + with open(out_csv, "w", newline="", encoding="utf-8") as fo: + for rec in rows: + if header is None: + header = list(columns) if columns else list(rec.keys()) + writer = csv.writer(fo) + writer.writerow(header) + writer.writerow([_csv_cell(rec.get(c)) for c in header]) + n += 1 + return n, (header or list(columns or [])) # ============= EOF ============================================= diff --git a/uv.lock b/uv.lock index 72a152f8b..9746570db 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" [[package]] @@ -1544,6 +1544,7 @@ dependencies = [ { name = "sqlalchemy-continuum" }, { name = "sqlalchemy-searchable" }, { name = "sqlalchemy-utils" }, + { name = "sqlparse" }, { name = "starlette" }, { name = "starlette-admin", extra = ["i18n"] }, { name = "typer" }, @@ -1658,6 +1659,7 @@ requires-dist = [ { name = "sqlalchemy-continuum", specifier = "==1.6.0" }, { name = "sqlalchemy-searchable", specifier = "==2.1.0" }, { name = "sqlalchemy-utils", specifier = "==0.42.1" }, + { name = "sqlparse", specifier = ">=0.5.5" }, { name = "starlette", specifier = "==0.52.1" }, { name = "starlette-admin", extras = ["i18n"], specifier = "==0.16.0" }, { name = "typer", specifier = "==0.24.1" }, @@ -2866,6 +2868,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 = "sqlparse" +version = "0.5.5" +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" } +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" }, +] + [[package]] name = "starlette" version = "0.52.1" From 84f9e9f84c0a989a9297599a9258fcb6df86922a Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 7 Jun 2026 22:04:08 -0600 Subject: [PATCH 019/160] feat(transfers): refresh materialized OGC views after mirror load Add refresh_materialized_views (REFRESH the geothermal materialized views, currently ogc_geothermal_wells_temperature_profile; skip any not present). The transfer_geothermal orchestrator calls it after the NMW_* mirror load so the materialized view reflects the freshly loaded data. Co-Authored-By: Claude Opus 4.8 --- transfers/nmw_mirror_transfer.py | 25 +++++++++++++++++++++++++ transfers/transfer_geothermal.py | 8 +++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/transfers/nmw_mirror_transfer.py b/transfers/nmw_mirror_transfer.py index 9bb0a961f..d541d0f0e 100644 --- a/transfers/nmw_mirror_transfer.py +++ b/transfers/nmw_mirror_transfer.py @@ -86,6 +86,10 @@ _CSV_DIR_ENV = "NMW_CSV_DIR" _CHUNK_SIZE = 2000 +# Materialized OGC views over the geothermal mirror that need a REFRESH after a +# (re)load. Regular views reflect the tables live and need no refresh. +_MATERIALIZED_VIEWS = ("ogc_geothermal_wells_temperature_profile",) + @dataclass class MirrorSpec: @@ -334,4 +338,25 @@ def transfer_nmw_mirror(session: Session, limit: int = None) -> tuple: return len(loaded), inserted, errors +def refresh_materialized_views(session: Session) -> list[str]: + """REFRESH the geothermal materialized OGC views (skip any not present). + + Call after a mirror (re)load so the materialized views reflect new data. + Plain (non-concurrent) REFRESH — runs inside the session transaction. + """ + refreshed = [] + for view in _MATERIALIZED_VIEWS: + exists = session.execute( + text("SELECT to_regclass(:n)"), {"n": f"public.{view}"} + ).scalar() + if not exists: + logger.warning("Skip refresh; materialized view missing: %s", view) + continue + logger.info("REFRESH MATERIALIZED VIEW %s", view) + session.execute(text(f'REFRESH MATERIALIZED VIEW "{view}"')) + session.commit() + refreshed.append(view) + return refreshed + + # ============= EOF ============================================= diff --git a/transfers/transfer_geothermal.py b/transfers/transfer_geothermal.py index a9d9d0b47..6945ea01d 100644 --- a/transfers/transfer_geothermal.py +++ b/transfers/transfer_geothermal.py @@ -54,7 +54,10 @@ from db.engine import session_ctx # noqa: E402 from services.env import get_bool_env # noqa: E402 from transfers.logger import logger # noqa: E402 -from transfers.nmw_mirror_transfer import transfer_nmw_mirror # noqa: E402 +from transfers.nmw_mirror_transfer import ( # noqa: E402 + refresh_materialized_views, + transfer_nmw_mirror, +) from transfers.reference_lexicon_transfer import transfer_reference_tables # noqa: E402 @@ -87,6 +90,9 @@ def run_geothermal_transfer(limit: int = None) -> dict: "rows_inserted": inserted, "errors": len(errors), } + logger.info("---- Refresh materialized OGC views ----") + with session_ctx() as session: + summary["refreshed_views"] = refresh_materialized_views(session) else: logger.info("Skipping NM_Wells mirror (TRANSFER_NMW_MIRROR=0)") From f880752484d4c0c314b3b7e83735ed542c00c43d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:47:28 +0000 Subject: [PATCH 020/160] build(deps): bump attrs from 25.4.0 to 26.1.0 Bumps [attrs](https://github.com/python-attrs/attrs) from 25.4.0 to 26.1.0. - [Release notes](https://github.com/python-attrs/attrs/releases) - [Changelog](https://github.com/python-attrs/attrs/blob/main/CHANGELOG.md) - [Commits](https://github.com/python-attrs/attrs/compare/25.4.0...26.1.0) --- updated-dependencies: - dependency-name: attrs dependency-version: 26.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- requirements.txt | 6 +++--- uv.lock | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b06bc4c87..2936d3327 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "asgiref==3.11.1", "asn1crypto==1.5.1", "asyncpg==0.31.0", - "attrs==25.4.0", + "attrs==26.1.0", "authlib==1.7.2", "bcrypt==4.3.0", "cachetools==5.5.2", diff --git a/requirements.txt b/requirements.txt index 0335fdcbd..75fafabe4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -213,9 +213,9 @@ asyncpg==0.31.0 \ --hash=sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44 \ --hash=sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696 # via ocotilloapi -attrs==25.4.0 \ - --hash=sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11 \ - --hash=sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373 +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 # via # aiohttp # jsonschema diff --git a/uv.lock b/uv.lock index 3b752b252..9f29181a7 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -248,11 +248,11 @@ wheels = [ [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] @@ -1618,7 +1618,7 @@ requires-dist = [ { name = "asgiref", specifier = "==3.11.1" }, { name = "asn1crypto", specifier = "==1.5.1" }, { name = "asyncpg", specifier = "==0.31.0" }, - { name = "attrs", specifier = "==25.4.0" }, + { name = "attrs", specifier = "==26.1.0" }, { name = "authlib", specifier = "==1.7.2" }, { name = "bcrypt", specifier = "==4.3.0" }, { name = "cachetools", specifier = "==5.5.2" }, From d24667eec36d7435e3155ea9b98f59d08107787e Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 8 Jun 2026 11:14:56 -0600 Subject: [PATCH 021/160] chore: regenerate requirements.txt with sqlparse after staging merge Co-Authored-By: Claude Opus 4.8 --- requirements.txt | 1015 +++++++++------------------------------------- 1 file changed, 192 insertions(+), 823 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0335fdcbd..23420654a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,81 +17,45 @@ aiohappyeyeballs==2.6.2 \ # aiohttp # ocotilloapi aiohttp==3.14.1 \ - --hash=sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5 \ --hash=sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983 \ - --hash=sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521 \ --hash=sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340 \ --hash=sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d \ --hash=sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a \ --hash=sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4 \ - --hash=sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a \ - --hash=sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f \ - --hash=sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee \ --hash=sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8 \ - --hash=sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb \ --hash=sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397 \ - --hash=sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05 \ --hash=sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8 \ --hash=sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09 \ - --hash=sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2 \ --hash=sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba \ --hash=sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf \ - --hash=sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271 \ --hash=sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5 \ - --hash=sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847 \ - --hash=sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264 \ - --hash=sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf \ - --hash=sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6 \ - --hash=sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df \ --hash=sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035 \ - --hash=sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126 \ --hash=sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6 \ --hash=sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35 \ - --hash=sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4 \ --hash=sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333 \ --hash=sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203 \ - --hash=sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c \ --hash=sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1 \ --hash=sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251 \ --hash=sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365 \ - --hash=sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b \ - --hash=sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621 \ --hash=sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94 \ --hash=sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da \ - --hash=sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491 \ --hash=sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe \ - --hash=sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d \ --hash=sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080 \ - --hash=sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42 \ - --hash=sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c \ --hash=sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397 \ --hash=sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9 \ - --hash=sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8 \ --hash=sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345 \ --hash=sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3 \ --hash=sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602 \ - --hash=sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2 \ - --hash=sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966 \ - --hash=sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192 \ - --hash=sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95 \ - --hash=sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3 \ - --hash=sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b \ --hash=sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444 \ - --hash=sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6 \ - --hash=sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573 \ - --hash=sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af \ --hash=sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15 \ --hash=sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe \ --hash=sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2 \ --hash=sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496 \ --hash=sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876 \ - --hash=sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817 \ --hash=sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448 \ - --hash=sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e \ --hash=sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6 \ --hash=sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd \ --hash=sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f \ - --hash=sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe \ --hash=sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c \ --hash=sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca \ --hash=sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c \ @@ -99,41 +63,23 @@ aiohttp==3.14.1 \ --hash=sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc \ --hash=sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0 \ --hash=sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0 \ - --hash=sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2 \ --hash=sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844 \ --hash=sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719 \ --hash=sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1 \ - --hash=sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3 \ --hash=sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178 \ - --hash=sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3 \ --hash=sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95 \ - --hash=sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730 \ - --hash=sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842 \ - --hash=sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd \ - --hash=sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d \ --hash=sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96 \ - --hash=sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85 \ --hash=sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1 \ - --hash=sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199 \ --hash=sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a \ --hash=sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588 \ --hash=sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec \ --hash=sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004 \ - --hash=sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480 \ - --hash=sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04 \ - --hash=sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8 \ - --hash=sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce \ - --hash=sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087 \ - --hash=sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505 \ --hash=sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780 \ - --hash=sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4 \ --hash=sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d \ --hash=sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca \ --hash=sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665 \ --hash=sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296 \ --hash=sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c \ - --hash=sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a \ - --hash=sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7 \ --hash=sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451 \ --hash=sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3 # via @@ -226,9 +172,9 @@ authlib==1.7.2 \ --hash=sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231 \ --hash=sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f # via ocotilloapi -babel==2.18.0 \ - --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ - --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 +babel==2.17.0 \ + --hash=sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d \ + --hash=sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2 # via # pygeoapi # starlette-admin @@ -305,123 +251,51 @@ cffi==2.0.0 \ --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ - --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ - --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ - --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ - --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ - --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ - --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ - --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ - --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ - --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ - --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ - --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ - --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ - --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ - --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ - --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ - --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ - --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ - --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ - --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ - --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ - --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ - --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ - --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ - --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ - --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ - --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ - --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ - --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ - --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ - --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ - --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ - --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ - --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ - --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ - --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ - --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ - --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ - --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ - --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ - --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ - --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ - --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ - --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ - --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ - --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ - --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ - --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ - --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ - --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ - --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf + --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 # via # cryptography # ocotilloapi -cfgv==3.5.0 \ - --hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \ - --hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132 +cfgv==3.4.0 \ + --hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \ + --hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560 # via pre-commit charset-normalizer==3.4.7 \ - --hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \ --hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \ - --hash=sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67 \ - --hash=sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4 \ --hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \ --hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \ - --hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \ - --hash=sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444 \ - --hash=sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153 \ - --hash=sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9 \ - --hash=sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01 \ - --hash=sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217 \ - --hash=sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b \ - --hash=sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c \ --hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \ - --hash=sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83 \ - --hash=sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5 \ - --hash=sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7 \ - --hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \ - --hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \ - --hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \ - --hash=sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42 \ --hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \ - --hash=sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df \ - --hash=sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e \ - --hash=sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207 \ --hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \ - --hash=sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734 \ - --hash=sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38 \ --hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \ --hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \ --hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \ @@ -429,99 +303,43 @@ charset-normalizer==3.4.7 \ --hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \ --hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \ --hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \ - --hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \ - --hash=sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53 \ - --hash=sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790 \ - --hash=sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c \ --hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \ - --hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \ - --hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \ --hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \ - --hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \ - --hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \ - --hash=sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776 \ --hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \ - --hash=sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265 \ - --hash=sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008 \ - --hash=sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943 \ - --hash=sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374 \ --hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \ --hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \ - --hash=sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5 \ - --hash=sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616 \ - --hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \ --hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \ --hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \ - --hash=sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752 \ --hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \ --hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \ - --hash=sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7 \ --hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \ --hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \ - --hash=sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4 \ - --hash=sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545 \ - --hash=sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706 \ - --hash=sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366 \ --hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \ - --hash=sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a \ --hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \ - --hash=sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00 \ --hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \ - --hash=sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a \ --hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \ --hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \ --hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \ - --hash=sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319 \ --hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \ - --hash=sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad \ - --hash=sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d \ --hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \ - --hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \ - --hash=sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0 \ - --hash=sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686 \ - --hash=sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34 \ - --hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \ - --hash=sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c \ - --hash=sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1 \ --hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \ - --hash=sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60 \ --hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \ - --hash=sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274 \ --hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \ --hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \ --hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \ - --hash=sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f \ - --hash=sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d \ --hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \ --hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \ - --hash=sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393 \ - --hash=sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1 \ - --hash=sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af \ --hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \ - --hash=sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00 \ - --hash=sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c \ - --hash=sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3 \ - --hash=sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7 \ --hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \ - --hash=sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e \ - --hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \ - --hash=sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8 \ - --hash=sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259 \ --hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \ --hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \ - --hash=sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30 \ --hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \ - --hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \ - --hash=sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24 \ - --hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \ --hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \ - --hash=sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc \ --hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \ --hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \ --hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \ --hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \ - --hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \ - --hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464 + --hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 # via # ocotilloapi # requests @@ -535,7 +353,6 @@ click==8.4.1 \ # pygeoapi # pygeofilter # rasterio - # typer # uvicorn cligj==0.7.2 \ --hash=sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27 \ @@ -551,123 +368,61 @@ colorama==0.4.6 ; sys_platform == 'win32' \ # via # click # pytest -coverage==7.14.1 \ - --hash=sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86 \ - --hash=sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd \ - --hash=sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d \ - --hash=sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5 \ - --hash=sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42 \ - --hash=sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de \ - --hash=sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548 \ - --hash=sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1 \ - --hash=sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7 \ - --hash=sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59 \ - --hash=sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906 \ - --hash=sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af \ - --hash=sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1 \ - --hash=sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d \ - --hash=sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1 \ - --hash=sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be \ - --hash=sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02 \ - --hash=sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42 \ - --hash=sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129 \ - --hash=sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e \ - --hash=sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be \ - --hash=sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e \ - --hash=sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65 \ - --hash=sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54 \ - --hash=sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1 \ - --hash=sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5 \ - --hash=sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df \ - --hash=sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47 \ - --hash=sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f \ - --hash=sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf \ - --hash=sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37 \ - --hash=sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4 \ - --hash=sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f \ - --hash=sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84 \ - --hash=sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1 \ - --hash=sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c \ - --hash=sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8 \ - --hash=sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e \ - --hash=sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec \ - --hash=sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d \ - --hash=sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54 \ - --hash=sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890 \ - --hash=sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b \ - --hash=sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d \ - --hash=sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2 \ - --hash=sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33 \ - --hash=sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9 \ - --hash=sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e \ - --hash=sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6 \ - --hash=sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce \ - --hash=sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247 \ - --hash=sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901 \ - --hash=sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36 \ - --hash=sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69 \ - --hash=sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416 \ - --hash=sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5 \ - --hash=sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500 \ - --hash=sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad \ - --hash=sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1 \ - --hash=sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b \ - --hash=sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b \ - --hash=sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a \ - --hash=sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e \ - --hash=sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee \ - --hash=sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07 \ - --hash=sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a \ - --hash=sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d \ - --hash=sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c \ - --hash=sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343 \ - --hash=sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4 \ - --hash=sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2 \ - --hash=sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8 \ - --hash=sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf \ - --hash=sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb \ - --hash=sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c \ - --hash=sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff \ - --hash=sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e \ - --hash=sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550 \ - --hash=sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860 \ - --hash=sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793 \ - --hash=sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f \ - --hash=sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851 \ - --hash=sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7 \ - --hash=sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332 \ - --hash=sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b \ - --hash=sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2 \ - --hash=sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d \ - --hash=sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a \ - --hash=sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef \ - --hash=sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474 \ - --hash=sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee \ - --hash=sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43 \ - --hash=sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034 \ - --hash=sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3 \ - --hash=sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c \ - --hash=sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d \ - --hash=sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7 \ - --hash=sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e \ - --hash=sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d \ - --hash=sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4 \ - --hash=sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9 \ - --hash=sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52 \ - --hash=sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a \ - --hash=sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c \ - --hash=sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253 \ - --hash=sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c + # typer +coverage==7.10.2 \ + --hash=sha256:0100b19f230df72c90fdb36db59d3f39232391e8d89616a7de30f677da4f532b \ + --hash=sha256:04c74f9ef1f925456a9fd23a7eef1103126186d0500ef9a0acb0bd2514bdc7cc \ + --hash=sha256:11333094c1bff621aa811b67ed794865cbcaa99984dedea4bd9cf780ad64ecba \ + --hash=sha256:12e52b5aa00aa720097d6947d2eb9e404e7c1101ad775f9661ba165ed0a28303 \ + --hash=sha256:14fb5b6641ab5b3c4161572579f0f2ea8834f9d3af2f7dd8fbaecd58ef9175cc \ + --hash=sha256:1a2e934e9da26341d342d30bfe91422bbfdb3f1f069ec87f19b2909d10d8dcc4 \ + --hash=sha256:228946da741558904e2c03ce870ba5efd9cd6e48cbc004d9a27abee08100a15a \ + --hash=sha256:248b5394718e10d067354448dc406d651709c6765669679311170da18e0e9af8 \ + --hash=sha256:2d358f259d8019d4ef25d8c5b78aca4c7af25e28bd4231312911c22a0e824a57 \ + --hash=sha256:2e980e4179f33d9b65ac4acb86c9c0dde904098853f27f289766657ed16e07b3 \ + --hash=sha256:5250bda76e30382e0a2dcd68d961afcab92c3a7613606e6269855c6979a1b0bb \ + --hash=sha256:52d708b5fd65589461381fa442d9905f5903d76c086c6a4108e8e9efdca7a7ed \ + --hash=sha256:5b9d538e8e04916a5df63052d698b30c74eb0174f2ca9cd942c981f274a18eaf \ + --hash=sha256:5c61675a922b569137cf943770d7ad3edd0202d992ce53ac328c5ff68213ccf4 \ + --hash=sha256:5d6e6d84e6dd31a8ded64759626627247d676a23c1b892e1326f7c55c8d61055 \ + --hash=sha256:651015dcd5fd9b5a51ca79ece60d353cacc5beaf304db750407b29c89f72fe2b \ + --hash=sha256:65b451949cb789c346f9f9002441fc934d8ccedcc9ec09daabc2139ad13853f7 \ + --hash=sha256:6eb586fa7d2aee8d65d5ae1dd71414020b2f447435c57ee8de8abea0a77d5074 \ + --hash=sha256:718044729bf1fe3e9eb9f31b52e44ddae07e434ec050c8c628bf5adc56fe4bdd \ + --hash=sha256:71d40b3ac0f26fa9ffa6ee16219a714fed5c6ec197cdcd2018904ab5e75bcfa3 \ + --hash=sha256:75cc1a3f8c88c69bf16a871dab1fe5a7303fdb1e9f285f204b60f1ee539b8fc0 \ + --hash=sha256:81bf6a32212f9f66da03d63ecb9cd9bd48e662050a937db7199dbf47d19831de \ + --hash=sha256:835f39e618099325e7612b3406f57af30ab0a0af350490eff6421e2e5f608e46 \ + --hash=sha256:8f34b09f68bdadec122ffad312154eda965ade433559cc1eadd96cca3de5c824 \ + --hash=sha256:916369b3b914186b2c5e5ad2f7264b02cff5df96cdd7cdad65dccd39aa5fd9f0 \ + --hash=sha256:95db3750dd2e6e93d99fa2498f3a1580581e49c494bddccc6f85c5c21604921f \ + --hash=sha256:95e23987b52d02e7c413bf2d6dc6288bd5721beb518052109a13bfdc62c8033b \ + --hash=sha256:96e5921342574a14303dfdb73de0019e1ac041c863743c8fe1aa6c2b4a257226 \ + --hash=sha256:9c1cd71483ea78331bdfadb8dcec4f4edfb73c7002c1206d8e0af6797853f5be \ + --hash=sha256:9f75dbf4899e29a37d74f48342f29279391668ef625fdac6d2f67363518056a1 \ + --hash=sha256:a3e853cc04987c85ec410905667eed4bf08b1d84d80dfab2684bb250ac8da4f6 \ + --hash=sha256:a7df481e7508de1c38b9b8043da48d94931aefa3e32b47dd20277e4978ed5b95 \ + --hash=sha256:a91e027d66eff214d88d9afbe528e21c9ef1ecdf4956c46e366c50f3094696d0 \ + --hash=sha256:abb57fdd38bf6f7dcc66b38dafb7af7c5fdc31ac6029ce373a6f7f5331d6f60f \ + --hash=sha256:aca7b5645afa688de6d4f8e89d30c577f62956fefb1bad021490d63173874186 \ + --hash=sha256:c2e117e64c26300032755d4520cd769f2623cde1a1d1c3515b05a3b8add0ade1 \ + --hash=sha256:ca07fa78cc9d26bc8c4740de1abd3489cf9c47cc06d9a8ab3d552ff5101af4c0 \ + --hash=sha256:d800705f6951f75a905ea6feb03fff8f3ea3468b81e7563373ddc29aa3e5d1ca \ + --hash=sha256:daaf98009977f577b71f8800208f4d40d4dcf5c2db53d4d822787cdc198d76e1 \ + --hash=sha256:e8415918856a3e7d57a4e0ad94651b761317de459eb74d34cc1bb51aad80f07e \ + --hash=sha256:e96649ac34a3d0e6491e82a2af71098e43be2874b619547c3282fc11d3840a4b \ + --hash=sha256:ea8d8fe546c528535c761ba424410bbeb36ba8a0f24be653e94b70c93fd8a8ca \ + --hash=sha256:f256173b48cc68486299d510a3e729a96e62c889703807482dbf56946befb5c8 \ + --hash=sha256:f287a25a8ca53901c613498e4a40885b19361a2fe8fbfdbb7f8ef2cad2a23f03 \ + --hash=sha256:f35481d42c6d146d48ec92d4e239c23f97b53a3f1fbd2302e7c64336f28641fe \ + --hash=sha256:fe024d40ac31eb8d5aae70215b41dafa264676caa4404ae155f77d2fa95c37bb # via pytest-cov cryptography==46.0.7 \ - --hash=sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65 \ --hash=sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832 \ --hash=sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067 \ --hash=sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de \ - --hash=sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4 \ --hash=sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0 \ --hash=sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b \ - --hash=sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968 \ --hash=sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef \ --hash=sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b \ --hash=sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4 \ @@ -693,10 +448,8 @@ cryptography==46.0.7 \ --hash=sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1 \ --hash=sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2 \ --hash=sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0 \ - --hash=sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455 \ --hash=sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842 \ --hash=sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457 \ - --hash=sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15 \ --hash=sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2 \ --hash=sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c \ --hash=sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb \ @@ -705,7 +458,6 @@ cryptography==46.0.7 \ --hash=sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902 \ --hash=sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246 \ --hash=sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022 \ - --hash=sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f \ --hash=sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e \ --hash=sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298 \ --hash=sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce @@ -713,14 +465,15 @@ cryptography==46.0.7 \ # authlib # cloud-sql-python-connector # google-auth + # joserfc # ocotilloapi -dateparser==1.4.0 \ - --hash=sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378 \ - --hash=sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4 +dateparser==1.3.0 \ + --hash=sha256:5bccf5d1ec6785e5be71cc7ec80f014575a09b4923e762f850e57443bddbf1a5 \ + --hash=sha256:8dc678b0a526e103379f02ae44337d424bd366aac727d3c6cf52ce1b01efbb5a # via pygeofilter -distlib==0.4.1 \ - --hash=sha256:9c2c552c68cbadc619f2d0ed3a69e27c351a3f4c9baa9ffb7df9e9cdc3d19a97 \ - --hash=sha256:c3804d0d2d4b5fcd44036eb860cb6660485fcdf5c2aba53dc324d805837ea65b +distlib==0.4.0 \ + --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ + --hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d # via virtualenv dnspython==2.8.0 \ --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \ @@ -732,9 +485,9 @@ dnspython==2.8.0 \ dotenv==0.9.9 \ --hash=sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9 # via ocotilloapi -ecdsa==0.19.2 \ - --hash=sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930 \ - --hash=sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399 +ecdsa==0.19.1 \ + --hash=sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3 \ + --hash=sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61 # via python-jose email-validator==2.3.0 \ --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ @@ -752,9 +505,9 @@ fastapi-pagination==0.15.14 \ --hash=sha256:61209b30172f928887a2537a85d144a2ae970edfadf160aab7c1fb15676dd651 \ --hash=sha256:b1c2ae46ae9952199f75d07726e3f11909ecd32bf12701a11f3e1080f05c4e91 # via ocotilloapi -filelock==3.29.1 \ - --hash=sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b \ - --hash=sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e +filelock==3.18.0 \ + --hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \ + --hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de # via # pygeoapi # virtualenv @@ -892,11 +645,7 @@ googleapis-common-protos==1.75.0 \ # google-api-core # ocotilloapi greenlet==3.5.1 \ - --hash=sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061 \ - --hash=sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19 \ - --hash=sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747 \ --hash=sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1 \ - --hash=sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10 \ --hash=sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0 \ --hash=sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9 \ --hash=sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64 \ @@ -906,71 +655,46 @@ greenlet==3.5.1 \ --hash=sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e \ --hash=sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c \ --hash=sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d \ - --hash=sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b \ --hash=sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986 \ --hash=sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78 \ --hash=sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2 \ --hash=sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a \ --hash=sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc \ --hash=sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b \ - --hash=sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5 \ --hash=sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829 \ --hash=sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea \ --hash=sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436 \ --hash=sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c \ - --hash=sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360 \ - --hash=sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f \ --hash=sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244 \ --hash=sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283 \ --hash=sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54 \ - --hash=sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f \ --hash=sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2 \ - --hash=sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188 \ - --hash=sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e \ - --hash=sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249 \ --hash=sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3 \ - --hash=sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563 \ --hash=sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de \ --hash=sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6 \ --hash=sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368 \ --hash=sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26 \ --hash=sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de \ - --hash=sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d \ - --hash=sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2 \ --hash=sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e \ - --hash=sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33 \ --hash=sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d \ --hash=sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c \ --hash=sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd \ - --hash=sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207 \ --hash=sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed \ - --hash=sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b \ --hash=sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62 \ - --hash=sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1 \ --hash=sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0 \ - --hash=sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c \ - --hash=sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823 \ --hash=sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab \ - --hash=sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523 \ --hash=sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd \ - --hash=sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c \ --hash=sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce \ --hash=sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c \ --hash=sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07 \ --hash=sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135 \ --hash=sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e \ - --hash=sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071 \ - --hash=sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f \ --hash=sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5 \ - --hash=sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee \ --hash=sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e \ --hash=sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f \ --hash=sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad \ - --hash=sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97 \ --hash=sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc \ - --hash=sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e \ - --hash=sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2 \ - --hash=sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed + --hash=sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e # via # ocotilloapi # sqlalchemy @@ -997,9 +721,9 @@ httpx==0.28.1 \ # via # apitally # ocotilloapi -identify==2.6.19 \ - --hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \ - --hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842 +identify==2.6.12 \ + --hash=sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2 \ + --hash=sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6 # via pre-commit idna==3.18 \ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ @@ -1011,9 +735,9 @@ idna==3.18 \ # ocotilloapi # requests # yarl -importlib-metadata==9.0.0 \ - --hash=sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7 \ - --hash=sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc +importlib-metadata==8.7.1 \ + --hash=sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb \ + --hash=sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151 # via opentelemetry-api iniconfig==2.3.0 \ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ @@ -1035,6 +759,10 @@ jinja2==3.1.6 \ # ocotilloapi # pygeoapi # starlette-admin +joserfc==1.7.1 \ + --hash=sha256:77d0b76514879c68c6f433bc5b7357a4ab72008ff1e33d8379fd11d72bd8ca81 \ + --hash=sha256:b3e3d655612e2e1ef67b2600f2f420e12e537b020208fab1761fad647319c164 + # via authlib jsonschema==4.26.0 \ --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce @@ -1053,9 +781,9 @@ mako==1.3.12 \ # via # alembic # ocotilloapi -markdown-it-py==4.2.0 \ - --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ - --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a +markdown-it-py==4.0.0 \ + --hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \ + --hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3 # via rich markupsafe==3.0.3 \ --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ @@ -1192,53 +920,34 @@ multidict==6.7.1 \ # aiohttp # ocotilloapi # yarl -nodeenv==1.10.0 \ - --hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \ - --hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb +nodeenv==1.9.1 \ + --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \ + --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9 # via pre-commit numpy==2.4.6 \ - --hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \ - --hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \ --hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \ --hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \ --hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \ - --hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \ --hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \ - --hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \ --hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \ --hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \ - --hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \ --hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \ --hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \ --hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \ - --hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \ --hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \ --hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \ - --hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \ --hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \ - --hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \ --hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \ - --hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \ --hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \ - --hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \ --hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \ - --hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \ --hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \ - --hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \ - --hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \ --hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \ - --hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \ --hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \ --hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \ --hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \ --hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \ --hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \ - --hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \ --hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \ - --hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \ - --hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \ - --hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \ - --hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \ --hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \ --hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \ --hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \ @@ -1248,26 +957,16 @@ numpy==2.4.6 \ --hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \ --hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \ --hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \ - --hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \ - --hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \ --hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \ --hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \ - --hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \ - --hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \ - --hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \ --hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \ --hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \ - --hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \ - --hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \ --hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \ --hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \ - --hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \ --hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \ - --hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \ --hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \ --hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \ --hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \ - --hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \ --hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20 # via # ocotilloapi @@ -1275,19 +974,19 @@ numpy==2.4.6 \ # pandas-stubs # rasterio # shapely -opentelemetry-api==1.42.1 \ - --hash=sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714 \ - --hash=sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716 +opentelemetry-api==1.39.1 \ + --hash=sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950 \ + --hash=sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c # via # opentelemetry-sdk # opentelemetry-semantic-conventions -opentelemetry-sdk==1.42.1 \ - --hash=sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d \ - --hash=sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7 +opentelemetry-sdk==1.39.1 \ + --hash=sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c \ + --hash=sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6 # via apitally -opentelemetry-semantic-conventions==0.63b1 \ - --hash=sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9 \ - --hash=sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682 +opentelemetry-semantic-conventions==0.60b1 \ + --hash=sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953 \ + --hash=sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb # via opentelemetry-sdk packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ @@ -1327,100 +1026,60 @@ phonenumbers==9.0.32 \ # via ocotilloapi pillow==12.2.0 \ --hash=sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9 \ - --hash=sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5 \ - --hash=sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987 \ --hash=sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9 \ --hash=sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b \ - --hash=sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f \ --hash=sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd \ - --hash=sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e \ --hash=sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e \ --hash=sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe \ --hash=sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795 \ --hash=sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601 \ - --hash=sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1 \ --hash=sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed \ --hash=sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea \ - --hash=sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5 \ - --hash=sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97 \ --hash=sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453 \ --hash=sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98 \ - --hash=sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa \ --hash=sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b \ - --hash=sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d \ - --hash=sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705 \ --hash=sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8 \ - --hash=sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024 \ - --hash=sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0 \ --hash=sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286 \ --hash=sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150 \ --hash=sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2 \ - --hash=sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3 \ - --hash=sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b \ --hash=sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f \ --hash=sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463 \ - --hash=sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940 \ --hash=sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166 \ --hash=sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed \ - --hash=sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f \ --hash=sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795 \ - --hash=sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780 \ --hash=sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7 \ --hash=sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1 \ - --hash=sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5 \ --hash=sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295 \ --hash=sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b \ --hash=sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354 \ - --hash=sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60 \ - --hash=sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65 \ - --hash=sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005 \ --hash=sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c \ --hash=sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be \ - --hash=sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5 \ --hash=sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06 \ --hash=sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae \ --hash=sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c \ - --hash=sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c \ --hash=sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612 \ - --hash=sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e \ - --hash=sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab \ - --hash=sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808 \ --hash=sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f \ --hash=sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e \ - --hash=sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909 \ - --hash=sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec \ - --hash=sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe \ --hash=sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50 \ --hash=sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4 \ - --hash=sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f \ - --hash=sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff \ --hash=sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5 \ --hash=sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb \ - --hash=sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414 \ --hash=sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1 \ - --hash=sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032 \ - --hash=sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76 \ - --hash=sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136 \ - --hash=sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e \ --hash=sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c \ --hash=sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3 \ --hash=sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea \ --hash=sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f \ --hash=sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104 \ - --hash=sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176 \ --hash=sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24 \ --hash=sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3 \ --hash=sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4 \ --hash=sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed \ --hash=sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43 \ - --hash=sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421 \ - --hash=sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7 \ - --hash=sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06 \ - --hash=sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5 + --hash=sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06 # via ocotilloapi -platformdirs==4.10.0 \ - --hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \ - --hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a +platformdirs==4.3.8 \ + --hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \ + --hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4 # via virtualenv pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ @@ -1435,99 +1094,55 @@ pre-commit==4.6.0 \ # via ocotilloapi propcache==0.5.2 \ --hash=sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427 \ - --hash=sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5 \ --hash=sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa \ --hash=sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7 \ --hash=sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a \ --hash=sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0 \ - --hash=sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660 \ --hash=sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94 \ --hash=sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917 \ - --hash=sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42 \ --hash=sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3 \ --hash=sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa \ - --hash=sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d \ - --hash=sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33 \ --hash=sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a \ - --hash=sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511 \ - --hash=sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0 \ - --hash=sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84 \ - --hash=sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c \ - --hash=sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66 \ --hash=sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821 \ --hash=sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb \ - --hash=sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e \ --hash=sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853 \ --hash=sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56 \ --hash=sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55 \ - --hash=sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6 \ --hash=sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704 \ - --hash=sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82 \ --hash=sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f \ - --hash=sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64 \ --hash=sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999 \ - --hash=sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b \ --hash=sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb \ --hash=sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d \ --hash=sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4 \ - --hash=sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab \ - --hash=sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f \ --hash=sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03 \ --hash=sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5 \ - --hash=sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba \ --hash=sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979 \ - --hash=sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b \ - --hash=sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144 \ - --hash=sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d \ --hash=sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e \ - --hash=sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67 \ --hash=sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117 \ --hash=sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa \ - --hash=sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb \ --hash=sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96 \ --hash=sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5 \ - --hash=sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476 \ --hash=sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191 \ - --hash=sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78 \ --hash=sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078 \ --hash=sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837 \ - --hash=sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a \ - --hash=sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba \ - --hash=sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe \ --hash=sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c \ - --hash=sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf \ - --hash=sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c \ - --hash=sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9 \ --hash=sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8 \ --hash=sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe \ - --hash=sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031 \ - --hash=sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913 \ --hash=sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d \ - --hash=sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf \ - --hash=sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f \ --hash=sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539 \ - --hash=sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b \ --hash=sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285 \ - --hash=sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959 \ --hash=sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d \ --hash=sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4 \ --hash=sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f \ --hash=sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836 \ - --hash=sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274 \ - --hash=sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d \ --hash=sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f \ --hash=sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e \ --hash=sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe \ - --hash=sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1 \ --hash=sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a \ --hash=sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39 \ - --hash=sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7 \ - --hash=sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a \ --hash=sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164 \ - --hash=sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e \ --hash=sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2 \ --hash=sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0 \ - --hash=sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0 \ --hash=sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335 \ --hash=sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568 \ --hash=sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4 \ @@ -1535,15 +1150,8 @@ propcache==0.5.2 \ --hash=sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2 \ --hash=sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370 \ --hash=sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4 \ - --hash=sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b \ - --hash=sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42 \ - --hash=sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a \ - --hash=sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e \ --hash=sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757 \ --hash=sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825 \ - --hash=sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0 \ - --hash=sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27 \ - --hash=sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf \ --hash=sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f \ --hash=sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d \ --hash=sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366 \ @@ -1604,71 +1212,27 @@ psutil==7.2.2 \ # via apitally psycopg2-binary==2.9.12 \ --hash=sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f \ - --hash=sha256:049366c6d884bdcd65d66e6ca1fdbebe670b56c6c9ba46f164e6667e90881964 \ - --hash=sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c \ --hash=sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2 \ - --hash=sha256:127467c6e476dd876634f17c3d870530e73ff454ff99bff73d36e80af28e1115 \ --hash=sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c \ - --hash=sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be \ - --hash=sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6 \ - --hash=sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c \ - --hash=sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c \ - --hash=sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c \ - --hash=sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d \ --hash=sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019 \ --hash=sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7 \ - --hash=sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3 \ --hash=sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777 \ --hash=sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd \ --hash=sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5 \ - --hash=sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39 \ --hash=sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c \ - --hash=sha256:5c7cb4cbf894a1d36c720d713de507952c7c58f66d30834708f03dbe5c822ccf \ - --hash=sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b \ - --hash=sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433 \ - --hash=sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d \ - --hash=sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4 \ --hash=sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290 \ --hash=sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2 \ - --hash=sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3 \ - --hash=sha256:718e1fc18edf573b02cb8aea868de8d8d33f99ce9620206aa9144b67b0985e94 \ --hash=sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d \ - --hash=sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b \ --hash=sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965 \ --hash=sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9 \ --hash=sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f \ --hash=sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354 \ - --hash=sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433 \ - --hash=sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9 \ - --hash=sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463 \ --hash=sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5 \ --hash=sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be \ - --hash=sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580 \ - --hash=sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4 \ - --hash=sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f \ - --hash=sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1 \ - --hash=sha256:a7e39a65b7d2a20e4ba2e0aaad1960b61cc2888d6ab047769f8347bd3c9ad915 \ --hash=sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033 \ - --hash=sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03 \ - --hash=sha256:ace94261f43850e9e79f6c56636c5e0147978ab79eda5e5e5ebf13ae146fc8fe \ - --hash=sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326 \ --hash=sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0 \ - --hash=sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e \ - --hash=sha256:ba3df2fc42a1cfa45b72cf096d4acb2b885937eedc61461081d53538d4a82a86 \ --hash=sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5 \ - --hash=sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e \ - --hash=sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06 \ - --hash=sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936 \ - --hash=sha256:cfa2517c94ea3af6deb46f81e1bbd884faa63e28481eb2f889989dd8d95e5f03 \ - --hash=sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56 \ - --hash=sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6 \ - --hash=sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256 \ - --hash=sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8 \ - --hash=sha256:ee2d84ef5eb6c04702d2e9c372ad557fb027f26a5d82804f749dfb14c7fdd2ab \ --hash=sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980 \ - --hash=sha256:f625abb7020e4af3432d95342daa1aa0db3fa369eed19807aa596367ba791b10 \ - --hash=sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a \ - --hash=sha256:fb1828cf3da68f99e45ebce1355d65d2d12b6a78fb5dfb16247aad6bdef5f5d2 \ --hash=sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e # via ocotilloapi pyasn1==0.6.3 \ @@ -1750,9 +1314,9 @@ pygeoapi==0.23.4 \ --hash=sha256:7f0fd854575a0da049b64907b56fc0f77ab97768414c1397897e60a0e563438d \ --hash=sha256:935a22761eb0d8736f7b0f2c8384672f5341577509803e35f33f6e78299221ae # via ocotilloapi -pygeofilter==0.4.0 \ - --hash=sha256:cbb4a5f14af0b87e4f0c0c81c659ff64e44351c98e9f61d36af515d896fa8a05 \ - --hash=sha256:ddb74c8233f4fd1b62b80a0ecf4e4f9aff178b8c61334754288d1622c8db71ec +pygeofilter==0.3.3 \ + --hash=sha256:8b9fec05ba144943a1e415b6ac3752ad6011f44aad7d1bb27e7ef48b073460bd \ + --hash=sha256:e719fcb929c6b60bca99de0cfde5f95bc3245cab50516c103dae1d4f12c4c7b6 # via pygeoapi pygeoif==1.6.0 \ --hash=sha256:02f84807dadbaf1941c4bb2a9ef1ebac99b1b0404597d2602efdbb58910c69c9 \ @@ -1861,80 +1425,17 @@ pytz==2025.2 \ # ocotilloapi # pandas # pygeoapi -pyyaml==6.0.3 \ - --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ - --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ - --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ - --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ - --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ - --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ - --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ - --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ - --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ - --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ - --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ - --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ - --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ - --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ - --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ - --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ - --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ - --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ - --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ - --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ - --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ - --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ - --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ - --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ - --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ - --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ - --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ - --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ - --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ - --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ - --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ - --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ - --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ - --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ - --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ - --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ - --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ - --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ - --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ - --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ - --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ - --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ - --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ - --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ - --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ - --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ - --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ - --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ - --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ - --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ - --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ - --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ - --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ - --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ - --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ - --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ - --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ - --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ - --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ - --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ - --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ - --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ - --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ - --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ - --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ - --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ - --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ - --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ - --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ - --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ - --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ - --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ - --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 +pyyaml==6.0.2 \ + --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \ + --hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \ + --hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \ + --hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \ + --hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \ + --hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \ + --hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \ + --hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \ + --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \ + --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba # via # pre-commit # pygeoapi @@ -1971,121 +1472,72 @@ referencing==0.37.0 \ # via # jsonschema # jsonschema-specifications -regex==2026.5.9 \ - --hash=sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d \ - --hash=sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611 \ - --hash=sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3 \ - --hash=sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d \ - --hash=sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4 \ - --hash=sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2 \ - --hash=sha256:0f03aa6898aaaac4592479821df16e68e8d0e29e903e65d8f2dfb2f19028a989 \ - --hash=sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf \ - --hash=sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c \ - --hash=sha256:15ee42209947f4ca045412eae98416317238163618ace2a8e54f99586a466733 \ - --hash=sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e \ - --hash=sha256:19c16ceb4a267a8789e25733e583983eeab9f0f8664e66b0bd1c5d21f14c2d4b \ - --hash=sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a \ - --hash=sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e \ - --hash=sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0 \ - --hash=sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c \ - --hash=sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b \ - --hash=sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346 \ - --hash=sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc \ - --hash=sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c \ - --hash=sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21 \ - --hash=sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a \ - --hash=sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca \ - --hash=sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d \ - --hash=sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6 \ - --hash=sha256:3b1e39888c5e0c7d92cea4fc777396c4a90363b05de75d02eb459a4752200808 \ - --hash=sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c \ - --hash=sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58 \ - --hash=sha256:446ddd671e43ab535810c4b21cff7104945c701d4a14d1e6d1cd6f4e445a8bea \ - --hash=sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c \ - --hash=sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8 \ - --hash=sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6 \ - --hash=sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9 \ - --hash=sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026 \ - --hash=sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2 \ - --hash=sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415 \ - --hash=sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6 \ - --hash=sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020 \ - --hash=sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06 \ - --hash=sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0 \ - --hash=sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa \ - --hash=sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0 \ - --hash=sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0 \ - --hash=sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af \ - --hash=sha256:6ba42b2e7e7f46cf68cc6a5ca36fa07959f9bbd9c6bdcc47b6ee76549a590248 \ - --hash=sha256:71b61c5bfe1c806332defc42ad6c780b3c55f661986d7f40283a3a88274b4c00 \ - --hash=sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e \ - --hash=sha256:7b92817338591505f282cf3864c145244b1edcf5381d237038df955001091538 \ - --hash=sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2 \ - --hash=sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178 \ - --hash=sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499 \ - --hash=sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994 \ - --hash=sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e \ - --hash=sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de \ - --hash=sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b \ - --hash=sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20 \ - --hash=sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e \ - --hash=sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88 \ - --hash=sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107 \ - --hash=sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14 \ - --hash=sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309 \ - --hash=sha256:954cc214c04663ee6d266fc61739cad83054683048de65c5bd1d640ad28098ac \ - --hash=sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070 \ - --hash=sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2 \ - --hash=sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad \ - --hash=sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919 \ - --hash=sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676 \ - --hash=sha256:a6a563446a41adc451393dc6b8e6ad87979efaee3c8738690a8d1b08ebead1b4 \ - --hash=sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270 \ - --hash=sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c \ - --hash=sha256:a9e1328e17c84c1a5d22ec9f785ecef4a967fab9a42b6a8dc3bcbebd0a0c9e44 \ - --hash=sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed \ - --hash=sha256:b310768746dd314ea6e2ff4cc89ef215426813396ff4e94ee8e6f7096c8b6e03 \ - --hash=sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4 \ - --hash=sha256:b4bb445ff3f725f59df8f6014edb547ee928ec7023a774f6a39a3f953038cbb2 \ - --hash=sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2 \ - --hash=sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff \ - --hash=sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41 \ - --hash=sha256:bfe1ce50cbfb569d74e1e4337da6468961f31dbea55fd85aa5de59c0947a805a \ - --hash=sha256:c010eb8caca74bdb40c07498d7ece26b4428fd3f04aa8a72c9ac6f79e8faaac6 \ - --hash=sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100 \ - --hash=sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451 \ - --hash=sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77 \ - --hash=sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48 \ - --hash=sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621 \ - --hash=sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f \ - --hash=sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1 \ - --hash=sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb \ - --hash=sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf \ - --hash=sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6 \ - --hash=sha256:d6b8a143aca6c39b446ea8092cde25cc8fe9304d4f5fecfbc1a9dbb0282703c2 \ - --hash=sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046 \ - --hash=sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f \ - --hash=sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66 \ - --hash=sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8 \ - --hash=sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041 \ - --hash=sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4 \ - --hash=sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8 \ - --hash=sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081 \ - --hash=sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372 \ - --hash=sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04 \ - --hash=sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962 \ - --hash=sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5 \ - --hash=sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9 \ - --hash=sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5 \ - --hash=sha256:ed457d8e98ae812ed7732bef7bf78de78e834eae0372a74e23ca90ef21d910f9 \ - --hash=sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555 \ - --hash=sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d \ - --hash=sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127 \ - --hash=sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225 \ - --hash=sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd \ - --hash=sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce \ - --hash=sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b \ - --hash=sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763 +regex==2026.2.19 \ + --hash=sha256:015088b8558502f1f0bccd58754835aa154a7a5b0bd9d4c9b7b96ff4ae9ba876 \ + --hash=sha256:02b9e1b8a7ebe2807cd7bbdf662510c8e43053a23262b9f46ad4fc2dfc9d204e \ + --hash=sha256:03d191a9bcf94d31af56d2575210cb0d0c6a054dbcad2ea9e00aa4c42903b919 \ + --hash=sha256:0d0e72703c60d68b18b27cde7cdb65ed2570ae29fb37231aa3076bfb6b1d1c13 \ + --hash=sha256:11c138febb40546ff9e026dbbc41dc9fb8b29e61013fa5848ccfe045f5b23b83 \ + --hash=sha256:127ea69273485348a126ebbf3d6052604d3c7da284f797bba781f364c0947d47 \ + --hash=sha256:17648e1a88e72d88641b12635e70e6c71c5136ba14edba29bf8fc6834005a265 \ + --hash=sha256:1e7a08622f7d51d7a068f7e4052a38739c412a3e74f55817073d2e2418149619 \ + --hash=sha256:2905ff4a97fad42f2d0834d8b1ea3c2f856ec209837e458d71a061a7d05f9f01 \ + --hash=sha256:294c0fb2e87c6bcc5f577c8f609210f5700b993151913352ed6c6af42f30f95f \ + --hash=sha256:2c1693ca6f444d554aa246b592355b5cec030ace5a2729eae1b04ab6e853e768 \ + --hash=sha256:2f914ae8c804c8a8a562fe216100bc156bfb51338c1f8d55fe32cf407774359a \ + --hash=sha256:2fedd459c791da24914ecc474feecd94cf7845efb262ac3134fe27cbd7eda799 \ + --hash=sha256:311fcccb76af31be4c588d5a17f8f1a059ae8f4b097192896ebffc95612f223a \ + --hash=sha256:3aa0944f1dc6e92f91f3b306ba7f851e1009398c84bfd370633182ee4fc26a64 \ + --hash=sha256:4071209fd4376ab5ceec72ad3507e9d3517c59e38a889079b98916477a871868 \ + --hash=sha256:43cdde87006271be6963896ed816733b10967baaf0e271d529c82e93da66675b \ + --hash=sha256:46e69a4bf552e30e74a8aa73f473c87efcb7f6e8c8ece60d9fd7bf13d5c86f02 \ + --hash=sha256:4a02faea614e7fdd6ba8b3bec6c8e79529d356b100381cec76e638f45d12ca04 \ + --hash=sha256:50f1ee9488dd7a9fda850ec7c68cad7a32fa49fd19733f5403a3f92b451dcf73 \ + --hash=sha256:516ee067c6c721d0d0bfb80a2004edbd060fffd07e456d4e1669e38fe82f922e \ + --hash=sha256:5390b130cce14a7d1db226a3896273b7b35be10af35e69f1cca843b6e5d2bb2d \ + --hash=sha256:5a8f28dd32a4ce9c41758d43b5b9115c1c497b4b1f50c457602c1d571fa98ce1 \ + --hash=sha256:5e3a31e94d10e52a896adaa3adf3621bd526ad2b45b8c2d23d1bbe74c7423007 \ + --hash=sha256:5e56c669535ac59cbf96ca1ece0ef26cb66809990cda4fa45e1e32c3b146599e \ + --hash=sha256:5ec1d7c080832fdd4e150c6f5621fe674c70c63b3ae5a4454cebd7796263b175 \ + --hash=sha256:6380f29ff212ec922b6efb56100c089251940e0526a0d05aa7c2d9b571ddf2fe \ + --hash=sha256:64128549b600987e0f335c2365879895f860a9161f283b14207c800a6ed623d3 \ + --hash=sha256:654dc41a5ba9b8cc8432b3f1aa8906d8b45f3e9502442a07c2f27f6c63f85db5 \ + --hash=sha256:655f553a1fa3ab8a7fd570eca793408b8d26a80bfd89ed24d116baaf13a38969 \ + --hash=sha256:6c8fb3b19652e425ff24169dad3ee07f99afa7996caa9dfbb3a9106cd726f49a \ + --hash=sha256:6fb8cb09b10e38f3ae17cc6dc04a1df77762bd0351b6ba9041438e7cc85ec310 \ + --hash=sha256:7187fdee1be0896c1499a991e9bf7c78e4b56b7863e7405d7bb687888ac10c4b \ + --hash=sha256:74ff212aa61532246bb3036b3dfea62233414b0154b8bc3676975da78383cac3 \ + --hash=sha256:77cfd6b5e7c4e8bf7a39d243ea05882acf5e3c7002b0ef4756de6606893b0ecd \ + --hash=sha256:790dbf87b0361606cb0d79b393c3e8f4436a14ee56568a7463014565d97da02a \ + --hash=sha256:80caaa1ddcc942ec7be18427354f9d58a79cee82dea2a6b3d4fd83302e1240d7 \ + --hash=sha256:8457c1bc10ee9b29cdfd897ccda41dce6bde0e9abd514bcfef7bcd05e254d411 \ + --hash=sha256:8497421099b981f67c99eba4154cf0dfd8e47159431427a11cfb6487f7791d9e \ + --hash=sha256:8abe671cf0f15c26b1ad389bf4043b068ce7d3b1c5d9313e12895f57d6738555 \ + --hash=sha256:8df08decd339e8b3f6a2eb5c05c687fe9d963ae91f352bc57beb05f5b2ac6879 \ + --hash=sha256:8e6e77cd92216eb489e21e5652a11b186afe9bdefca8a2db739fd6b205a9e0a4 \ + --hash=sha256:8edda06079bd770f7f0cf7f3bba1a0b447b96b4a543c91fe0c142d034c166161 \ + --hash=sha256:93d881cab5afdc41a005dba1524a40947d6f7a525057aa64aaf16065cf62faa9 \ + --hash=sha256:997862c619994c4a356cb7c3592502cbd50c2ab98da5f61c5c871f10f22de7e5 \ + --hash=sha256:9cbc69eae834afbf634f7c902fc72ff3e993f1c699156dd1af1adab5d06b7fe7 \ + --hash=sha256:9e6693b8567a59459b5dda19104c4a4dbbd4a1c78833eacc758796f2cfef1854 \ + --hash=sha256:9fff45852160960f29e184ec8a5be5ab4063cfd0b168d439d1fc4ac3744bf29e \ + --hash=sha256:a09ae430e94c049dc6957f6baa35ee3418a3a77f3c12b6e02883bd80a2b679b0 \ + --hash=sha256:a178df8ec03011153fbcd2c70cb961bc98cbbd9694b28f706c318bee8927c3db \ + --hash=sha256:ab780092b1424d13200aa5a62996e95f65ee3db8509be366437439cdc0af1a9f \ + --hash=sha256:b5100acb20648d9efd3f4e7e91f51187f95f22a741dcd719548a6cf4e1b34b3f \ + --hash=sha256:b9ab8dec42afefa6314ea9b31b188259ffdd93f433d77cad454cd0b8d235ce1c \ + --hash=sha256:bcf57d30659996ee5c7937999874504c11b5a068edc9515e6a59221cc2744dd1 \ + --hash=sha256:c0761d7ae8d65773e01515ebb0b304df1bf37a0a79546caad9cbe79a42c12af7 \ + --hash=sha256:c0924c64b082d4512b923ac016d6e1dcf647a3560b8a4c7e55cbbd13656cb4ed \ + --hash=sha256:c13228fbecb03eadbfd8f521732c5fda09ef761af02e920a3148e18ad0e09968 \ + --hash=sha256:c227f2922153ee42bbeb355fd6d009f8c81d9d7bdd666e2276ce41f53ed9a743 \ + --hash=sha256:c7e121a918bbee3f12ac300ce0a0d2f2c979cf208fb071ed8df5a6323281915c \ + --hash=sha256:cce8027010d1ffa3eb89a0b19621cdc78ae548ea2b49fea1f7bfb3ea77064c2b \ + --hash=sha256:d00c95a2b6bfeb3ea1cb68d1751b1dfce2b05adc2a72c488d77a780db06ab867 \ + --hash=sha256:d793c5b4d2b4c668524cd1651404cfc798d40694c759aec997e196fe9729ec60 \ + --hash=sha256:d96162140bb819814428800934c7b71b7bffe81fb6da2d6abc1dcca31741eca3 \ + --hash=sha256:e581f75d5c0b15669139ca1c2d3e23a65bb90e3c06ba9d9ea194c377c726a904 \ + --hash=sha256:ea8dfc99689240e61fb21b5fc2828f68b90abf7777d057b62d3166b7c1543c4c # via dateparser requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ @@ -2096,9 +1548,9 @@ requests==2.34.2 \ # google-cloud-storage # ocotilloapi # pygeoapi -rich==14.3.3 \ - --hash=sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d \ - --hash=sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b +rich==14.3.2 \ + --hash=sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69 \ + --hash=sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8 # via typer rpds-py==0.30.0 \ --hash=sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136 \ @@ -2167,7 +1619,6 @@ rsa==4.9.1 \ --hash=sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762 \ --hash=sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75 # via - # google-auth # ocotilloapi # python-jose scramp==1.4.8 \ @@ -2235,60 +1686,25 @@ sniffio==1.3.1 \ sqlalchemy==2.0.50 \ --hash=sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064 \ --hash=sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093 \ - --hash=sha256:0a31c5963d58d3e3d11c5b97709e248305705de1fdf51ec3bf396674c5898b7e \ - --hash=sha256:0e104e196f457ec608eb8af736c5eb4c6bc58f481b546f485a7f9c628ee532be \ - --hash=sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e \ - --hash=sha256:0fe7822866f3a9fc5f3db21a290ce8961a53050115f05edf9402b6a5feb92a9f \ - --hash=sha256:0fec460e18cdbb4c7773531122ce9a27e96c6ca17af3933941d94da475ad2c86 \ - --hash=sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600 \ - --hash=sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a \ - --hash=sha256:13b85b20f9ab714a666df9d8e72e253ec33c16c7e1e375c877e5bf6367a3e917 \ --hash=sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39 \ --hash=sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a \ - --hash=sha256:1aa6e403663a9c43c8fef7ce4bdb4cf48bcd8d352e91deda2a99f963270bd508 \ - --hash=sha256:1c5f858fe79c9f5d8fda065c06186356acb7f8df3cd52dbd5ee3f200e4b144f5 \ --hash=sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e \ - --hash=sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb \ - --hash=sha256:27b7062af702c61994e8806ad87e42d0a2c879e0a8e5c61c7f69d81dabe24fdf \ --hash=sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3 \ --hash=sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f \ - --hash=sha256:2c1920cde9d741ba3dda9b1aa5acd8c23ea17780ccfb2252d01878d5d0d628d3 \ - --hash=sha256:2dab927761d9108550f0cf8e66ff21af56f907a0ce0a689793db615e2b55f62c \ --hash=sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db \ --hash=sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70 \ - --hash=sha256:3d10700bd519573f6ce5badbabbfe7f5baea84cdf370f2cbbfb4be28dfddbf1d \ - --hash=sha256:409a8121b917116b035bedc5e532ad470c74a2d279f6c302100985b6304e9f9e \ - --hash=sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89 \ - --hash=sha256:4a8e8af330cbb3a1931d3d6c91b239fc2ef135f7dd471dfa34c575028e0b1fa8 \ - --hash=sha256:51b637a84f9fa35ae1f9017e786cb142974a25305085e1b378b3647a67f65ad3 \ - --hash=sha256:545eae198d37bcf837a10ede3684e2af32458d6f35c597c35c2de7502dc38fc4 \ --hash=sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5 \ - --hash=sha256:66e374271ecb7101273f57af1a62446a953d327eec4f8089147de57c591bbacc \ - --hash=sha256:68b154b08088b4ec32bb4d2958bfbb50e57549f91a4cd3e7f928e3553ed69031 \ --hash=sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e \ - --hash=sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615 \ - --hash=sha256:7af6eeb84985bf840ba779018ff9424d61ff69b52e66b8789d3c8da7bf5341b2 \ - --hash=sha256:7b1ddb7b5fc60dfa9df6a487f06a143c77def47c0351849da2bcea59b244a56c \ - --hash=sha256:7e36efdcc5493f8024ec873a4ee3855bfd2de0c5b19eba16f920e9d2a0d28622 \ - --hash=sha256:83a9fce296b7e052316d8c6943237b31b9c00f58ca9c253f2d165df52637a293 \ --hash=sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873 \ --hash=sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8 \ --hash=sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9 \ - --hash=sha256:9602c07b03e1449747ecb69f9998a7194a589124475788b370adce57c9e9a56e \ --hash=sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f \ - --hash=sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7 \ - --hash=sha256:adc0fe7d38d8c8058f7421c25508fcbc74df38233a42aa8324409844122dce8f \ --hash=sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9 \ --hash=sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d \ --hash=sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d \ --hash=sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52 \ --hash=sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51 \ --hash=sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0 \ - --hash=sha256:e195687f1af431c9515416288373b323b6eb599f774409814e89e9d603a56e39 \ - --hash=sha256:e6e814658818fd165e749e3d8490ef16cc7f379a118c37ada8b0589ffbaaac22 \ - --hash=sha256:e8e1b0f6a4dcd9b4839e2320afb5df37a6981cbc20ff9c423ae11c5537bdbd21 \ - --hash=sha256:ea1a8a2db4b2217d456c8d7a873bfc605f06fe3584d315264ea18c2a17585d0b \ - --hash=sha256:eefd9a03cc0047b14153872d228499d048bd7deaf926109c9ec25b15157b8e23 \ --hash=sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086 \ --hash=sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb # via @@ -2313,6 +1729,10 @@ sqlalchemy-utils==0.42.1 \ # via # ocotilloapi # sqlalchemy-searchable +sqlparse==0.5.5 \ + --hash=sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba \ + --hash=sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e + # via ocotilloapi starlette==0.52.1 \ --hash=sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74 \ --hash=sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933 @@ -2386,116 +1806,65 @@ uvicorn==0.49.0 \ --hash=sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f \ --hash=sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3 # via ocotilloapi -virtualenv==21.4.2 \ - --hash=sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c \ - --hash=sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae +virtualenv==20.32.0 \ + --hash=sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56 \ + --hash=sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0 # via pre-commit -werkzeug==3.1.8 \ - --hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \ - --hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44 +werkzeug==3.1.6 \ + --hash=sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25 \ + --hash=sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131 # via flask yarl==1.24.2 \ --hash=sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b \ - --hash=sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30 \ --hash=sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc \ - --hash=sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f \ - --hash=sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae \ --hash=sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8 \ - --hash=sha256:15c0b5e49d3c44e2a0b93e6a49476c5edad0a7686b92c395765a7ea775572a75 \ - --hash=sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a \ - --hash=sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c \ --hash=sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461 \ --hash=sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44 \ --hash=sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b \ - --hash=sha256:246d32a53a947c8f0189f5d699cbd4c7036de45d9359e13ba238d1239678c727 \ --hash=sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9 \ --hash=sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd \ --hash=sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67 \ --hash=sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420 \ - --hash=sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db \ --hash=sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50 \ --hash=sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b \ - --hash=sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50 \ - --hash=sha256:349de4701dc3760b6e876628423a8f147ef4f5599d10aba1e10702075d424ed9 \ - --hash=sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1 \ --hash=sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488 \ - --hash=sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2 \ - --hash=sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f \ - --hash=sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d \ - --hash=sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003 \ --hash=sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536 \ --hash=sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a \ - --hash=sha256:4b85b8825e631295ff4bc8943f7471d54c533a9360bbe15ebb38e018b555bb8a \ --hash=sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa \ --hash=sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f \ - --hash=sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e \ - --hash=sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035 \ - --hash=sha256:5249a113065c2b7a958bc699759e359cd61cfc81e3069662208f48f191b7ed12 \ --hash=sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe \ - --hash=sha256:5cb0f995a901c36be096ccbf4c673591c2faabbe96279598ffaec8c030f85bf4 \ - --hash=sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294 \ - --hash=sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7 \ --hash=sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761 \ - --hash=sha256:60de6742447fbbf697f16f070b8a443f1b5fe6ca3826fbef9fe70ecd5328e643 \ - --hash=sha256:64480fb3e4d4ed9ed71c48a91a477384fc342a50ca30071d2f8a88d51d9c9413 \ --hash=sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57 \ - --hash=sha256:6b208bb939099b4b297438da4e9b25357f0b1c791888669b963e45b203ea9f36 \ --hash=sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14 \ --hash=sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd \ - --hash=sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5 \ --hash=sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656 \ - --hash=sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad \ - --hash=sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c \ - --hash=sha256:7f4425fa244fbf530b006d0c5f79ce920114cfff5b4f5f6056e669f8e160fdc0 \ --hash=sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992 \ - --hash=sha256:819ca24f8eafcfb683c1bd5f44f2f488cea1274eb8944731ffd2e1f10f619342 \ --hash=sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1 \ --hash=sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf \ --hash=sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024 \ --hash=sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986 \ --hash=sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb \ - --hash=sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d \ --hash=sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543 \ - --hash=sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d \ --hash=sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed \ --hash=sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617 \ - --hash=sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996 \ --hash=sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8 \ - --hash=sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2 \ --hash=sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3 \ --hash=sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535 \ --hash=sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630 \ --hash=sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215 \ --hash=sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592 \ --hash=sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf \ - --hash=sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b \ - --hash=sha256:acf93187c3710e422368eb768aee98db551ec7c85adc250207a95c16548ab7ac \ --hash=sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0 \ --hash=sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92 \ - --hash=sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122 \ --hash=sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1 \ - --hash=sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8 \ - --hash=sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576 \ --hash=sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8 \ - --hash=sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712 \ --hash=sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1 \ - --hash=sha256:d162677af8d5d3d6ebab8394b021f4d041ac107a4b705873148a77a49dc9e1b2 \ - --hash=sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b \ --hash=sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a \ - --hash=sha256:e26acf20c26cb4fefc631fdb75aca2a6b8fa8b7b5d7f204fb6a8f1e63c706f53 \ - --hash=sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1 \ --hash=sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d \ --hash=sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208 \ --hash=sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0 \ - --hash=sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c \ --hash=sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607 \ - --hash=sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c \ --hash=sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8 \ - --hash=sha256:f408eace7e22a68b467a0562e0d27d322f91fe3eaaa6f466b962c6cfaea9fa39 \ - --hash=sha256:f4b0352fd41fd34b6651934606268816afd6914d09626f9bcbbf018edb0afb3f \ - --hash=sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8 \ - --hash=sha256:f5f5c6ec23a9043f2d139cc072f53dd23168d202a334b9b2fda8de4c3e890d90 \ - --hash=sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45 \ --hash=sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2 \ --hash=sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056 \ --hash=sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14 From 2b520e3c955a7d7c5873b918cddb348698ae2753 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:32:15 +0000 Subject: [PATCH 022/160] build(deps): bump starlette from 0.52.1 to 1.0.1 Bumps [starlette](https://github.com/Kludex/starlette) from 0.52.1 to 1.0.1. - [Release notes](https://github.com/Kludex/starlette/releases) - [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md) - [Commits](https://github.com/Kludex/starlette/compare/0.52.1...1.0.1) --- updated-dependencies: - dependency-name: starlette dependency-version: 1.0.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- requirements.txt | 6 +++--- uv.lock | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b06bc4c87..3b3464b15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,7 +93,7 @@ dependencies = [ "sqlalchemy-continuum==1.6.0", "sqlalchemy-searchable==2.1.0", "sqlalchemy-utils==0.42.1", - "starlette==0.52.1", + "starlette==1.0.1", "starlette-admin[i18n]==0.16.1", "typer==0.26.7", "typing-extensions==4.15.0", diff --git a/requirements.txt b/requirements.txt index 137a817a9..cafe1bea2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2317,9 +2317,9 @@ sqlalchemy-utils==0.42.1 \ # via # ocotilloapi # sqlalchemy-searchable -starlette==0.52.1 \ - --hash=sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74 \ - --hash=sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933 +starlette==1.0.1 \ + --hash=sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f \ + --hash=sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd # via # apitally # fastapi diff --git a/uv.lock b/uv.lock index 3b752b252..aeb975c24 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -1694,7 +1694,7 @@ requires-dist = [ { name = "sqlalchemy-continuum", specifier = "==1.6.0" }, { name = "sqlalchemy-searchable", specifier = "==2.1.0" }, { name = "sqlalchemy-utils", specifier = "==0.42.1" }, - { name = "starlette", specifier = "==0.52.1" }, + { name = "starlette", specifier = "==1.0.1" }, { name = "starlette-admin", extras = ["i18n"], specifier = "==0.16.1" }, { name = "typer", specifier = "==0.26.7" }, { name = "typing-extensions", specifier = "==4.15.0" }, @@ -2907,14 +2907,14 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, ] [[package]] From 10bd9364bd23eb1b67277cb7ff233992d8778e20 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 10 Jun 2026 14:08:55 -0600 Subject: [PATCH 023/160] ci: fix production deploy skipping when invoked via workflow_call A reusable workflow inherits the caller's event context, so github.event_name is never 'workflow_call' in CD_production. The release-please-invoked deploy therefore fell through to github.event.release.tag_name (empty on push) and the job skipped itself (run 27302759728, v1.1.0 never deployed). Rely on inputs.tag_name being empty outside workflow_call instead. Co-Authored-By: Claude Fable 5 --- .github/workflows/CD_production.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index e135876e1..45f8dd2a8 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -26,13 +26,16 @@ jobs: # (v*.*.*, v*.*.*-*, v*.*.*[a-z]*). startsWith() is a cheap pre-filter; # the "Validate release tag" step enforces the strict regex. The tag comes # from the workflow_call input or, for the release event, the payload. - if: ${{ startsWith((github.event_name == 'workflow_call' && inputs.tag_name) || github.event.release.tag_name, 'v') }} + # NOTE: a called workflow inherits the CALLER's event context, so + # github.event_name is never 'workflow_call' here — inputs.tag_name is + # simply empty when triggered by a release event, so `||` falls through. + if: ${{ startsWith(inputs.tag_name || github.event.release.tag_name, 'v') }} runs-on: ubuntu-latest environment: production env: - DEPLOY_TAG: ${{ (github.event_name == 'workflow_call' && inputs.tag_name) || github.event.release.tag_name }} + DEPLOY_TAG: ${{ inputs.tag_name || github.event.release.tag_name }} steps: - name: Validate release tag matches version pattern From 69f5b74c39d082605602170b816494313cbdf59d Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Wed, 10 Jun 2026 15:38:30 -0600 Subject: [PATCH 024/160] ci: staging RC releases + automated back/forward-merge PRs (#713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Implements the full release flow: staging RCs → production stable releases → hotfixes, with automated branch convergence. Documented in `docs/release-flow.md`. ``` feature (jir*) ──PR──▶ staging ──promotion PR──▶ production ──release PR──▶ tag vX.Y.Z ──▶ prod deploy │ │ ▲ │ ▼ ▼ │ auto forward-merge PR ▼ auto back-merge PR testing svc staging svc deploy hotfix/vX.Y.(Z+1) production → staging + RC release PR (off tag) (syncs staging manifest) → tag vX.Y.Z-rc.N ``` ## Changes - **`release-please-config.staging.json` + `.release-please-manifest.staging.json`** (new): staging cuts `vX.Y.Z-rc.N` GitHub prereleases. `versioning: prerelease` so successive merges bump `rc.N` only. `release-type: simple` + separate `CHANGELOG-rc.md` so `pyproject.toml` is never touched on staging (`1.2.0-rc.1` is not PEP 440 valid and would break `uv export` in CD_staging) and promotion merges don't conflict on version files. - **`release-please-config.json`**: removed stray `prerelease: true`/`prerelease-type: rc` (wrong for stable releases). - **`release-please.yml`**: runs on `staging` too, selecting config/manifest by branch. `deploy-production` and `forward-merge` gated with `github.ref_name != 'staging'` — RC releases never deploy production. - **`forward-merge.yml`** (new): after a stable release, opens `production → staging` back-merge PR with the staging manifest synced to the released version; after a hotfix release, opens `hotfix/vX.Y.Z → production` PR. `workflow_dispatch` fallback for manual runs (used to propagate a hotfix from production to staging). Fails loudly with instructions on merge conflict — never forces. - **`hotfix-start.yml`**: summary text updated (forward-merge PR now automatic). - **`docs/release-flow.md`** (new, force-added — `docs/` is gitignored but `refine-json-filters-and-virtual-fields.md` set precedent): branch roles, lifecycles, version-file ownership, conflict rules, caveats. ## Caveats / follow-ups - **`FORWARD_MERGE_TOKEN` secret recommended**: PRs created with default `GITHub_TOKEN` don't trigger `pull_request` workflows (no CI on automated PRs). Fine-grained PAT or GitHub App token with `contents: write` + `pull-requests: write`. Falls back to `GITHUB_TOKEN` (close/reopen PR to kick CI). - **Needs a production → staging back-merge first**: staging's history doesn't contain the `v1.1.0` tag commit yet; staging release-please needs it to bound its commit scan. Merge a `production → staging` PR (or run the new `forward-merge` workflow with `source_branch=production`, `tag_name=v1.1.0` once this lands). - Production-side behavior (deploy gate, forward-merge trigger) goes live only after this reaches `production`; staging RC behavior is live on merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- .github/workflows/forward-merge.yml | 154 ++++++++++++++++++++++++++ .github/workflows/hotfix-start.yml | 9 +- .github/workflows/release-please.yml | 30 ++++- .release-please-manifest.staging.json | 3 + docs/release-flow.md | 107 ++++++++++++++++++ release-please-config.json | 4 +- release-please-config.staging.json | 31 ++++++ 7 files changed, 325 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/forward-merge.yml create mode 100644 .release-please-manifest.staging.json create mode 100644 docs/release-flow.md create mode 100644 release-please-config.staging.json diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml new file mode 100644 index 000000000..9567229a8 --- /dev/null +++ b/.github/workflows/forward-merge.yml @@ -0,0 +1,154 @@ +name: forward-merge + +# Keeps the release branches converged automatically: +# - source_branch = production -> open a back-merge PR production -> staging, +# syncing .release-please-manifest.staging.json to the released stable +# version so the next staging RC computes from the new baseline. +# - source_branch = hotfix/vX.Y.Z -> open a forward-merge PR into production. +# (After that PR merges, run this workflow manually with +# source_branch=production to propagate the hotfix on to staging — no +# release is cut on production by the hotfix merge, so the automatic +# trigger doesn't fire.) +# +# Invoked via workflow_call from release-please.yml after a release is cut, or +# manually via workflow_dispatch. +# +# NOTE: PRs created with the default GITHUB_TOKEN do not trigger +# `pull_request` workflows (tests will not run on the PR). Set the +# FORWARD_MERGE_TOKEN secret (fine-grained PAT or GitHub App token with +# contents:write + pull-requests:write) to get CI on these PRs; without it the +# workflow falls back to GITHUB_TOKEN and you can close/reopen the PR to kick +# CI. + +on: + workflow_call: + inputs: + tag_name: + description: Release tag that was just cut (e.g. v1.2.0) + required: true + type: string + source_branch: + description: Branch the release was cut on (production or hotfix/vX.Y.Z) + required: true + type: string + workflow_dispatch: + inputs: + tag_name: + description: Release tag driving the merge (e.g. v1.2.0) + required: true + type: string + source_branch: + description: Branch to merge from (production or hotfix/vX.Y.Z) + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + back-merge-to-staging: + if: ${{ inputs.source_branch == 'production' }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.FORWARD_MERGE_TOKEN || github.token }} + TAG: ${{ inputs.tag_name }} + steps: + - uses: actions/checkout@v6.0.3 + with: + fetch-depth: 0 + token: ${{ secrets.FORWARD_MERGE_TOKEN || github.token }} + + - name: Set up git user + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Create merge branch off staging and merge production + id: merge + run: | + VERSION="${TAG#v}" + BRANCH="merge/production-into-staging-${VERSION}" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + git checkout -b "$BRANCH" origin/staging + if ! git merge --no-ff "origin/production" -m "merge: production ${TAG} into staging"; then + { + echo "### Back-merge conflict" + echo "" + echo "Merging \`production\` (${TAG}) into \`staging\` conflicts." + echo "Resolve manually:" + echo '```' + echo "git checkout -b $BRANCH origin/staging" + echo "git merge origin/production # resolve conflicts" + echo "git push origin $BRANCH" + echo "gh pr create --base staging --head $BRANCH --title 'chore: merge production ${TAG} into staging'" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + - name: Sync staging release-please manifest to released version + run: | + VERSION="${TAG#v}" + jq --arg v "$VERSION" '."." = $v' .release-please-manifest.staging.json > manifest.tmp + mv manifest.tmp .release-please-manifest.staging.json + if ! git diff --quiet -- .release-please-manifest.staging.json; then + git add .release-please-manifest.staging.json + git commit -m "chore: sync staging release-please manifest to ${TAG}" + fi + + # Retry-safe: skip if a PR is already open for this merge; force-with-lease + # handles a branch left behind by a previous partial run. + - name: Push branch and open PR + run: | + BRANCH="${{ steps.merge.outputs.branch }}" + EXISTING="$(gh pr list --base staging --head "$BRANCH" --state open --json number --jq '.[0].number // empty')" + if [ -n "$EXISTING" ]; then + echo "Back-merge PR #$EXISTING already open for $BRANCH; nothing to do." + exit 0 + fi + git push --force-with-lease origin "$BRANCH" + { + echo "Automated back-merge after release \`${TAG}\`." + echo "" + echo "- Brings the release commit and tag history into \`staging\`." + echo "- Syncs \`.release-please-manifest.staging.json\` to \`${TAG#v}\` so the next staging RC versions from the new stable baseline." + echo "" + echo "If version files conflict with in-flight staging work, accept either side — release-please rewrites them on the next Release PR." + } > pr-body.md + gh pr create \ + --base staging \ + --head "$BRANCH" \ + --title "chore: merge production ${TAG} into staging" \ + --body-file pr-body.md + + forward-merge-to-production: + if: ${{ startsWith(inputs.source_branch, 'hotfix/') }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.FORWARD_MERGE_TOKEN || github.token }} + TAG: ${{ inputs.tag_name }} + SOURCE: ${{ inputs.source_branch }} + steps: + - uses: actions/checkout@v6.0.3 + + # Retry-safe: skip if a PR is already open from this hotfix branch. + - name: Open PR hotfix -> production + run: | + EXISTING="$(gh pr list --base production --head "$SOURCE" --state open --json number --jq '.[0].number // empty')" + if [ -n "$EXISTING" ]; then + echo "Forward-merge PR #$EXISTING already open for $SOURCE; nothing to do." + exit 0 + fi + { + echo "Automated forward-merge of hotfix release \`${TAG}\`." + echo "" + echo "- Brings the hotfix commits and release bookkeeping into \`production\`." + echo "- No new release is cut by this merge (the \`${TAG}\` release commit is already included)." + echo "- After merging, run the \`forward-merge\` workflow manually with \`source_branch=production\` and \`tag_name=${TAG}\` to propagate the hotfix on to \`staging\`." + } > pr-body.md + gh pr create \ + --base production \ + --head "$SOURCE" \ + --title "chore: merge ${SOURCE} (${TAG}) into production" \ + --body-file pr-body.md diff --git a/.github/workflows/hotfix-start.yml b/.github/workflows/hotfix-start.yml index 095bcd74d..7bb5ddfcf 100644 --- a/.github/workflows/hotfix-start.yml +++ b/.github/workflows/hotfix-start.yml @@ -5,8 +5,9 @@ name: hotfix-start # 1. Run this workflow (optionally pin base_tag; default = latest v*.*.*). # 2. Push fix commit(s) to the new hotfix/vX.Y.(Z+1) branch via PR. # 3. release-please opens a Release PR on the hotfix branch. -# 4. Merge it -> tag vX.Y.(Z+1) -> CD (Production) deploys. -# 5. Open a forward-merge PR from hotfix/vX.Y.(Z+1) back into production. +# 4. Merge it -> tag vX.Y.(Z+1) -> CD (Production) deploys, and a +# forward-merge PR into production is opened automatically +# (forward-merge.yml). on: workflow_dispatch: @@ -85,6 +86,6 @@ jobs: echo "Next steps:" echo "1. Open a fix PR targeting \`${{ steps.next.outputs.branch }}\` (Conventional Commit title, \`fix:\` prefix)." echo "2. After merge, release-please will open a Release PR on the hotfix branch." - echo "3. Merge the Release PR -> tag \`v${{ steps.next.outputs.version }}\` -> CD (Production) deploys." - echo "4. Open a forward-merge PR \`${{ steps.next.outputs.branch }}\` -> \`production\`." + echo "3. Merge the Release PR -> tag \`v${{ steps.next.outputs.version }}\` -> CD (Production) deploys and a forward-merge PR \`${{ steps.next.outputs.branch }}\` -> \`production\` opens automatically." + echo "4. After merging that PR, run the \`forward-merge\` workflow (source_branch=production) to propagate the hotfix to \`staging\`." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index f6cd5bb7e..9269a3b1a 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -4,6 +4,7 @@ on: push: branches: - production + - staging - 'hotfix/v*' permissions: @@ -17,23 +18,40 @@ jobs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} steps: + # staging uses its own config/manifest pair: prerelease (rc) versioning, + # separate changelog, and its own version state so the rc line never + # collides with the stable line tracked in .release-please-manifest.json. - id: release uses: googleapis/release-please-action@v5 with: - config-file: release-please-config.json - manifest-file: .release-please-manifest.json + config-file: ${{ github.ref_name == 'staging' && 'release-please-config.staging.json' || 'release-please-config.json' }} + manifest-file: ${{ github.ref_name == 'staging' && '.release-please-manifest.staging.json' || '.release-please-manifest.json' }} target-branch: ${{ github.ref_name }} - # When release-please actually cuts a release, deploy it. The release is - # created with GITHUB_TOKEN, whose events don't trigger other workflows, so we - # invoke the production deploy inline (same run) instead of relying on the + # When release-please cuts a stable or hotfix release, deploy it. RC releases + # on staging never deploy production. The release is created with + # GITHUB_TOKEN, whose events don't trigger other workflows, so we invoke the + # production deploy inline (same run) instead of relying on the # `release: published` event reaching CD_production. deploy-production: needs: release-please - if: ${{ needs.release-please.outputs.release_created == 'true' }} + if: ${{ needs.release-please.outputs.release_created == 'true' && github.ref_name != 'staging' }} permissions: contents: read uses: ./.github/workflows/CD_production.yml with: tag_name: ${{ needs.release-please.outputs.tag_name }} secrets: inherit + + # Keep branches converged after a release: + # - stable release on production -> open PR production -> staging + # (carries the release commit/tag history and syncs the staging manifest) + # - hotfix release on hotfix/v* -> open PR hotfix/vX.Y.Z -> production + forward-merge: + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' && github.ref_name != 'staging' }} + uses: ./.github/workflows/forward-merge.yml + with: + tag_name: ${{ needs.release-please.outputs.tag_name }} + source_branch: ${{ github.ref_name }} + secrets: inherit diff --git a/.release-please-manifest.staging.json b/.release-please-manifest.staging.json new file mode 100644 index 000000000..5fdd88304 --- /dev/null +++ b/.release-please-manifest.staging.json @@ -0,0 +1,3 @@ +{ + ".": "1.1.0" +} diff --git a/docs/release-flow.md b/docs/release-flow.md new file mode 100644 index 000000000..3e08e569b --- /dev/null +++ b/docs/release-flow.md @@ -0,0 +1,107 @@ +# Release Flow + +How code moves from a feature branch to production, how versions are cut, and +how hotfixes work. The mechanics live in `.github/workflows/`; this doc is the +map. + +## Branch roles + +| Branch | Role | Deploys to | Versioning | +|---|---|---|---| +| `jir*` | feature / ticket branches | `ocotillo-api-testing` (every push) | none | +| `staging` | integration branch (default) | `ocotillo-api-staging` (every push) | `vX.Y.Z-rc.N` prereleases via release-please | +| `production` | release branch | `ocotillo-api` (on release tag) | `vX.Y.Z` stable releases via release-please | +| `hotfix/vX.Y.Z` | emergency patch off a release tag | `ocotillo-api` (on release tag) | `vX.Y.Z` patch release via release-please | + +## The flow + +``` +feature (jir*) ──PR──▶ staging ──promotion PR──▶ production ──release PR──▶ tag vX.Y.Z ──▶ prod deploy + │ │ ▲ │ + ▼ ▼ │ auto forward-merge PR ▼ auto back-merge PR +testing svc staging svc deploy hotfix/vX.Y.(Z+1) production → staging + + RC release PR (off tag, via (syncs staging manifest) + → tag vX.Y.Z-rc.N hotfix-start.yml) +``` + +### 1. Feature → staging (RC line) + +1. Branch `jir*` off `staging`; every push deploys the testing service + (`CD_testing.yml`). +2. Merge the PR into `staging` (Conventional Commit title — `feat:`, `fix:`, + etc.; enforced by `pr-title-lint.yml`). +3. Every push to `staging` deploys the staging service (`CD_staging.yml`) — + continuous, unversioned, date-stamped tag. +4. release-please (staging config) maintains an **RC Release PR** + (`chore(staging): release X.Y.Z-rc.N`). Merging it tags `vX.Y.Z-rc.N` and + publishes a GitHub **prerelease**. This is a versioned checkpoint of what's + on staging — it never deploys production. +5. Successive merges after an RC bump only the `rc.N` counter + (`versioning: prerelease`), so the target version is stable until promoted. + +Cut an RC (merge the RC Release PR) when staging is in a state you intend to +promote — the RC tag is the thing you tested. + +### 2. Staging → production (stable release) + +1. Open a **promotion PR** `staging → production` (manual; this is the + "we want to ship what's on staging" decision). +2. Merging it makes release-please (production config) open a **Release PR** + (`chore(production): release X.Y.Z`). +3. Merging the Release PR tags `vX.Y.Z`, publishes the GitHub release, and the + same workflow run invokes `CD_production.yml` via `workflow_call` + (releases created with `GITHUB_TOKEN` don't emit events that trigger other + workflows, hence the inline call). +4. `forward-merge.yml` then opens an automatic **back-merge PR + `production → staging`**, which also syncs + `.release-please-manifest.staging.json` to the released version so the next + RC computes from the new stable baseline. Merge it promptly. + +### 3. Hotfix + +1. Run the `hotfix-start` workflow (optionally pinning `base_tag`). It creates + `hotfix/vX.Y.(Z+1)` off the release tag. +2. Open a fix PR targeting the hotfix branch (`fix:` title). +3. release-please (production config, hotfix branch) opens a Release PR; + merging it tags `vX.Y.(Z+1)` and deploys production. +4. `forward-merge.yml` automatically opens **`hotfix/vX.Y.(Z+1)` → + `production`**. Merge it. No new release is cut (the release commit is + already in the branch). +5. Propagate to staging: run the `forward-merge` workflow manually with + `source_branch=production` and the hotfix tag (the hotfix merge doesn't cut + a release on production, so the automatic trigger doesn't fire). + +## Version-file ownership + +| File | Written by | Lives meaningfully on | +|---|---|---| +| `.release-please-manifest.json` | release-please on `production` / `hotfix/v*` | production | +| `.release-please-manifest.staging.json` | release-please on `staging`; synced by back-merge PRs | staging | +| `pyproject.toml` version | release-please stable releases only (python release-type) | production | +| `CHANGELOG.md` | stable releases | production | +| `CHANGELOG-rc.md` | RC releases | staging | +| `version.txt` | RC releases (simple release-type bookkeeping) | staging | + +RC releases deliberately do **not** touch `pyproject.toml`: `1.2.0-rc.1` is +not a valid PEP 440 version and would break `uv export` during staging +deploys, and skipping it avoids promotion-merge conflicts. + +**Conflict rule:** if any of these files conflict during a merge, accept +either side and move on — release-please rewrites them on the next Release PR. +The manifests are the only state that matters, and the back-merge PR syncs the +staging one explicitly. + +## Caveats + +- **CI on automated PRs:** PRs created with the default `GITHUB_TOKEN` do not + trigger `pull_request` workflows. Set the `FORWARD_MERGE_TOKEN` repo secret + (fine-grained PAT or GitHub App token, `contents: write` + + `pull-requests: write`) so back-merge/forward-merge PRs get CI. Without it, + close and reopen the PR to kick CI. +- **Workflow changes go live per branch:** release-please and the deploy + workflows resolve at the pushed branch's commit. A workflow fix merged to + `staging` does nothing for production releases until it reaches + `production`. +- **Tag visibility:** the staging release-please needs the last stable tag's + commit in `staging` history to bound its commit scan — another reason to + merge back-merge PRs promptly after each release. diff --git a/release-please-config.json b/release-please-config.json index 4011c4f86..2b810a65a 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -21,9 +21,7 @@ ], "packages": { ".": { - "package-name": "OcotilloAPI", - "prerelease": true, - "prerelease-type": "rc" + "package-name": "OcotilloAPI" } } } diff --git a/release-please-config.staging.json b/release-please-config.staging.json new file mode 100644 index 000000000..8236f48ed --- /dev/null +++ b/release-please-config.staging.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-v-in-tag": true, + "include-component-in-tag": false, + "bump-minor-pre-major": false, + "bump-patch-for-minor-pre-major": false, + "versioning": "prerelease", + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "deps", "section": "Dependencies" }, + { "type": "revert", "section": "Reverts" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "chore", "section": "Chores", "hidden": true }, + { "type": "refactor", "section": "Refactors", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build", "hidden": true }, + { "type": "ci", "section": "CI", "hidden": true }, + { "type": "style", "section": "Style", "hidden": true } + ], + "packages": { + ".": { + "package-name": "OcotilloAPI", + "prerelease": true, + "prerelease-type": "rc", + "changelog-path": "CHANGELOG-rc.md" + } + } +} From 05d534f21348d4650d5ced0fb68e5e702deb4c50 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:41:50 +0000 Subject: [PATCH 025/160] chore(staging): release 1.2.0-rc --- .release-please-manifest.staging.json | 2 +- CHANGELOG-rc.md | 233 ++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG-rc.md diff --git a/.release-please-manifest.staging.json b/.release-please-manifest.staging.json index 5fdd88304..6362badf3 100644 --- a/.release-please-manifest.staging.json +++ b/.release-please-manifest.staging.json @@ -1,3 +1,3 @@ { - ".": "1.1.0" + ".": "1.2.0-rc" } diff --git a/CHANGELOG-rc.md b/CHANGELOG-rc.md new file mode 100644 index 000000000..996a74b8c --- /dev/null +++ b/CHANGELOG-rc.md @@ -0,0 +1,233 @@ +# Changelog + +## [1.2.0-rc](https://github.com/DataIntegrationGroup/OcotilloAPI/compare/v1.1.0...v1.2.0-rc) (2026-06-10) + + +### Features + +* add app.template.yaml for dynamic app configuration and update CI/CD scripts to render app.yaml ([9274d83](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/9274d830a464215286f4c8c470ca87f54f5f8ebf)) +* add auto-generation prefix extraction for well IDs with new regex support ([dcd49b4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/dcd49b40c622e31e6539998429e02c07c85a8c60)) +* add auto-generation prefix extraction for well IDs with new regex support ([3c11d05](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3c11d0592792e65d914d013c8140eed7e2ee5b62)) +* add command to import project area boundaries and create associated OGC view ([919222e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/919222e81c47fa29c6dbfc2920412a70fb3903f0)) +* add Dockerfile to set up PostGIS with pg_cron for scheduled tasks ([dc424cf](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/dc424cf0fde24bda9957ee5b7e236287aa464979)) +* add field event limit to well details API and enhance response validation ([ac76eba](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ac76eba0cf1430fd9ec147be34063d747ff933f7)) +* add handling for null slope in water trend materialized view ([945d140](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/945d14096ddd1ae53271e871af7061b06263cb60)) +* add location properties to water well response and enhance test coverage ([8d0be60](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8d0be60b90592c36735b5824137738bfe4b6d5dc)) +* add materialized views for latest TDS, depth to water trend, and water well summary ([924652c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/924652c729d2c2e66957a19b77ea3407ed7f393a)) +* add minor chemistry wells materialized view and update related configurations ([181218a](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/181218ac2f8f2e08c3b9375565cf0a0c7b947d77)) +* add normalized chemistry results materialized view and update related configurations ([54a6686](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/54a66866130e0b3e7d5f427e56edb120c8571884)) +* add normalized chemistry results materialized view and update related configurations ([814092b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/814092b2b916efd453e94598107ff1fb01cb343e)) +* add POST /asset/upload-and-record endpoint for digital asset upload (BDMS-828) ([#683](https://github.com/DataIntegrationGroup/OcotilloAPI/issues/683)) ([f9e538c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/f9e538c58f64513b6c3f13e3144c366658666ec7)) +* add refresh command for pygeoapi materialized views and schedule nightly job ([2ad195f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2ad195f770737c4023138ce3bf3076bf8592284e)) +* add refresh step for materialized views in production and staging workflows ([e64d6c8](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e64d6c8d379a723afa534606c4a9280139024412)) +* add restore-local-db command for restoring local databases from SQL dumps ([3d17413](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3d174137427e7fc745dfbbc092b7f892f315e364)) +* add restore-local-db command for restoring local databases from SQL dumps ([f5530ff](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/f5530ff34ca49711ecf97127413329a9ee68cbbe)) +* add test for normalized major chemistry to ensure latest results are used ([e15d366](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e15d366144f9359b9e450e861edef7420d271337)) +* add transfer-results command for generating transfer results summary ([b4764b2](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b4764b2e9e06d93fdf536b8e38a3bf058f8ee215)) +* add validation for missing well_name_point_id column in CSV processing ([3b7c561](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3b7c561c553b55187e147c2a0ff245ed83c03b44)) +* add water elevation materialized view and update configuration ([925be66](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/925be668639b6b050f6cc4f56e131e4780d3f4bf)) +* add water elevation materialized view and update configuration ([cc40afb](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/cc40afbc59b604e6404a9a3b27b28979a6616d73)) +* add well smoke test command and enhance contact handling with missing value checks ([7824779](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/782477977828bc3879c57f238db23f5a24784acc)) +* add WellTransferResultsBuilder for summarizing well transfer outcomes ([1195f1a](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1195f1a15adf15703c6a8a6ef857aaed8ca84952)) +* **api/thing:** Add optional flag to see contacts ([5cfe398](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/5cfe3980744ed482c157fa8a046a75aeeda2122c)) +* change views to materialized views for depth and TDS data in pygeoapi ([9c74fac](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/9c74facadfde56ffa73f6db9f5c47a194ca62a64)) +* **cli:** add progress updates for well inventory imports ([467c87e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/467c87e4a2f4074c7e9e53ccf3f5ac79da98bf20)) +* **cli:** add scoped transfer command for targeted imports ([b2b60e5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b2b60e56a94ba3b699068c67638670ac53e19ab1)) +* **core:** add "Windmill" term to lexicon with category `well_pump_type` ([1df8425](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1df8425ef0c9aa4f391ccfa8bda046380d657535)) +* **core:** add legacy site notes field to lexicon ([ef96f7b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ef96f7b22621f54eef93aede5fcff705aa543ec0)) +* **core:** expand lexicon with new terms for water-related categories ([4d74d1b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4d74d1bec091eb6b5d0f49ba1d5cc41129018d3f)) +* create supporting views for pygeoapi OGC API integration ([a2e8f57](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a2e8f57b0be908ca11f007e6994fd192d6c3bc46)) +* **db, schemas:** add support for legacy site notes ([b533da4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b533da4481825e9a5fec4543f0ff4074b8bd4302)) +* disable default IAM authentication for Cloud SQL connections and allow password handling ([2581f61](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2581f610d394c84cbb63f610bed8220dac2b6d52)) +* enable database drop and rebuild for unit tests ([a2baff6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a2baff6f0b6aadc9d56509da4094e0e9b6c78a78)) +* enable IAM authentication by default for Cloud SQL connections ([2261484](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2261484ffc34cfb3ce2b838212cd6baff0c17d4b)) +* enforce IAM authentication for Cloud SQL connections by removing password handling ([c2f4b86](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c2f4b86ff2d8afe8414d2062abc767e893e66108)) +* enhance CSV processing to handle duplicate contact names and organizations ([21ad925](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/21ad9254fd17cfbf4c9554f9941a562e18bcd04c)) +* enhance data transfer handling by logging skipped records and updating row processing ([d2f4f1f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d2f4f1f9f5b20e1d6935a5437e8ad80598c29fe2)) +* enhance database configuration handling for Cloud SQL with IAM authentication ([4ea1c80](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4ea1c80a82ae7a8815ab8725a683deddc728a313)) +* enhance GCS upload handling with async support and improved error logging ([0e9b5af](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0e9b5af5dcd8ee9750fe3ddec48594435b5e1621)) +* enhance GCS upload handling with async support and improved error logging ([355706d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/355706d88cf216651565b7d273056f16fd36ed88)) +* enhance GCS upload handling with async support and improved error logging ([250f2c7](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/250f2c7a5781ea92b0fb8f5381bfa56dde8ab9b4)) +* enhance logging with debug timing for various operations and ad… ([16b7197](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/16b7197f880d2c91b4e75f411a89632040f87182)) +* enhance logging with debug timing for various operations and add well export endpoint ([65e51c6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/65e51c66783fd4fc7a10a8adb291666d3b94b315)) +* enhance project area import with created and skipped counts ([989e678](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/989e678589cfec167e2c114285744532b727495a)) +* enhance project area import with created and skipped counts ([dbceb75](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/dbceb7566b72faa5c489cc7d9ad7b036bb5c1359)) +* enhance pygeoapi configuration with new thing collections and supporting views for groundwater monitoring ([04d05b5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/04d05b534e0550f95a2b3a9008b340ce39bdcb0b)) +* enhance refresh job scheduling with improved privilege handling and error management ([43852fe](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/43852fefb058ffdf536efc0bc1f4d3063e612663)) +* enhance refresh job scheduling with improved privilege handling… ([2f97d25](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2f97d257332e2294777db57abe9d968447eaa847)) +* enhance SQL queries and add null handling for water data responses ([2384f11](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2384f1196dcd723d616ba72a6e57ea322129c33d)) +* enhance test for refreshing materialized views with execution options tracking ([2d40419](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2d40419eda42d17537005bc06fc2f5fdfce2be99)) +* enhance test workflow by adding database readiness checks and pg_cron extension ([c8d6957](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c8d69574d929f8106386e8bac0948df12840183a)) +* enhance validation error handling for contact fields and improve error extraction ([bc89558](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/bc8955885098c2411a5cb67517b4c0af458545e5)) +* enhance water elevation calculations to support both meters and feet ([75f727d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/75f727d5b15b54fb3b3f61a1788fe751ea645c64)) +* enhance water well details with site name, historic depth notes, and field event participants ([c3c7648](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c3c7648328504ff865dbddee0b099b7d9c4b1c76)) +* enhance water well details with site name, historic depth notes, and field event participants ([c142980](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c1429804b15638a586b5f46bdcc79aadbc40fa24)) +* enhance well details API response with field events and related data structures ([60fe719](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/60fe71935c5af99605393b3d90421a46d921b771)) +* ensure feature IDs are consistently treated as strings in tests ([627a27b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/627a27b764180ee3c47f7f27770ca3a207a10251)) +* first release under versioning standard ([#681](https://github.com/DataIntegrationGroup/OcotilloAPI/issues/681)) ([809977d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/809977d0fdf2317b3f5db03f6eb449f487bf4cc1)) +* fix water elevation units to feet in materialized view and update related tests ([d8b3f71](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d8b3f715fe1da59e454c34a5f373253afdfcf70c)) +* fix water elevation units to feet in materialized view and update related tests ([e3e4fde](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e3e4fde51a74d61e81c36fd5caa173106a4a2c22)) +* handle internal error in pg_cron job unscheduling for better robustness ([cf51d48](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/cf51d489138f815b86b1650fee3e35fae6b6f9ac)) +* implement API concurrency fix strategy by converting async rout… ([b240b21](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b240b2116a50362555980a79b53837c7412a242d)) +* implement API concurrency fix strategy by converting async route handlers to sync and enhancing error handling ([7c053eb](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7c053eb64e315e191d3c321cfc19554f9a69d396)) +* implement dynamic loading of pygeoapi app and improve description formatting ([4750f9d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4750f9d849933f22c7c62318dfd7d5f1a12b041d)) +* implement dynamic loading of pygeoapi app and improve description formatting ([a242b36](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a242b36ed9058d82b0ed9f005cea5c92e3d00f50)) +* implement TransferResultsBuilder and comparison specs for transfer input validation ([e8d8bf3](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e8d8bf35cdd937d97fea9dc4150c5d7d33a7ae16)) +* improve note retrieval by sorting notes and enhance loading strategy for field event participants ([58113bf](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/58113bfe2d6ea6f443604cb2c53288ea0291d525)) +* integrate pygeoapi for OGC API - Features endpoints and update related configurations ([a440b55](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a440b5509bbdade111d9a3786b9b67a28e636607)) +* **lexicon:** add new organization terms ([2be9fbd](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2be9fbd766648f990d2e32c4b1403de1572a17c5)) +* **lexicon:** add new organization terms for Santa Ana Pueblo, Village of Hope, and WSP ([c6164bb](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c6164bb7c5db38773ff9da7040768ebc73a19210)) +* **lexicon:** add new organization terms in support of the 2026-05-26 well inventory ingestion ([69fb435](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/69fb435c539721d57d28997dc282ceaba448fded)) +* make various fields nullable and enhance data transfer handling ([fd7e243](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fd7e2430c8f51eed6dcdb9d71799f532bf656bd1)) +* **migrations:** make NMA_SurfaceWaterData.thing_id nullable ([2d4d8ff](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2d4d8ff185690ef10e79ca2b9715511d47ef5e30)) +* **migrations:** new migration script to drop minor trace chemistry sample/analyte unique constraint ([a5943d8](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a5943d8d7aaa1717ff66f562eb0d16e9424a8ec3)) +* normalize database password environment variable and enhance config security ([73f26bf](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/73f26bf96d9da754f0db8dbd403fc50dee2579f4)) +* optimize logging for request and asset upload processes, and enhance debug timing functionality ([2ffbd27](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2ffbd273c7d0cab9dbca91d965ebe7f8ddcdd458)) +* optimize water level data transfer by implementing chunked deployment prefetching and COPY insert method ([040e787](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/040e7875d057bd0c34100082a72361ac528e57ba)) +* optimize water level data transfer by implementing chunked deployment prefetching and COPY insert method ([982a63c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/982a63c6b985431cbf86e024617dfd395db8e772)) +* refactor app initialization to import from main module ([4923c1e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4923c1e6be69e82ee05310a53d3796895c9eba68)) +* refactor location CTE for materialized views and enhance path validation ([0ff7bcc](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0ff7bccd8ab0f055e7d99d90eed2e9e42b2d0b89)) +* refactor location CTE for materialized views and enhance path validation ([b4a2841](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b4a2841cfc6d262fa4a22a23ea87d2bad6f3d44a)) +* refactor test connection context management in CLI commands ([2bb0032](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2bb00328b697b22ca36c2e7d46a065d051751475)) +* remove pg_cron dependency and related scheduling logic for improved flexibility ([a18c1d8](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a18c1d8b4d49efd74ff8fac096f485bec438622b)) +* remove pg_cron dependency and related scheduling logic for improved flexibility ([98cb0ad](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/98cb0add37db3ad0f14fffd3c0bccf88056dc78d)) +* remove pg_cron extension from test database setup for improved flexibility ([c21c254](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c21c25420640976157c20db1cec254a8fa0ac441)) +* remove unused PYGEOAPI environment variables and add PYGEOAPI_SERVER_URL for Cloud SQL IAM authentication ([19f016b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/19f016bb2af686c6c0a63ebfaf7a8e146d0b0dee)) +* rename normalized chemistry results to major chemistry results and update related configurations ([7cebfcc](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7cebfcc9b9d592bf7526e26920b16255adc07dfd)) +* rename wells collection to water_wells for consistency ([c8b31ce](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c8b31ce73e9d7493ae079f9708374a7b864342ed)) +* **schemas:** add `monitoring_status` field to `thing` schema ([a7e0632](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a7e0632b2a7daeaae8360baf7d0ba0eb47c7c9d9)) +* **schemas:** add alias validation for well inventory fields ([1e0b253](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1e0b253d7f72de9d86fd0d76a6731489c1a7e035)) +* **schemas:** enhance well inventory schema with flexible validation and new fields ([6c38157](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/6c38157df265d7cfad3a9073d404cba2906170aa)) +* **services:** improve well inventory handling and align well inventory source fields in support of schema alignment and database mapping ([6c5d46e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/6c5d46ea7af242a260c80aaf7fa41ed577ba8cad)) +* simplify location DataFrame caching by removing threading lock ([c9cf672](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c9cf672566b2b4f37741ca145e3b49a6389c2a4a)) +* simplify password retrieval logic for pygeoapi configuration ([9d24983](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/9d249836327cc3a11a1141e50205935372b9505e)) +* streamline password validation for pygeoapi configuration ([72c6b00](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/72c6b00c2c457745db70dfe08649f13fbb0a1f70)) +* **test:** ensure more robust water level tests ([44c598a](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/44c598ab822706436b3995c5ed05a82c9ccc159e)) +* **test:** print exit_code when assert fails ([3ad295a](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3ad295a231abb2970276548c82bb3694d7bb178d)) +* **tests:** add validation error handling for various invalid CSV field values ([bc051f3](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/bc051f38d61b09afe231e5e689a3ab6ef7298452)) +* **tests:** add validation error handling for various invalid CSV field values ([32b4c54](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/32b4c54ce537ceb06e3a88bc586a64ed161b2370)) +* **tests:** adjust validation scenarios to allow partial imports with 1 well ([4a4e249](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4a4e24923c23b4887d513496eb4626433a13382c)) +* **tests:** relax validation rules and expand enum coverage in well-inventory-csv feature ([4382fd5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4382fd5581ae198c3f3a2fd0e5331c873704bdd0)) +* **tests:** relax validation rules and expand enum coverage in well-inventory-csv feature ([9cbaaa2](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/9cbaaa26fc05fb2e909abe4a405c5c1ff8ef3b5b)) +* **tests:** relax validation rules and expand enum coverage in well-inventory-csv feature ([56f6cbf](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/56f6cbf6782f9dda8e62e79d73ec4a3f3f238462)) +* **tests:** update validation error message for well_pump_type field ([2c3cde4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2c3cde404cd56ce9564cb1ef1af3c4a3723716d9)) +* **thing_helper:** add handling for `monitoring_status` in status history updates ([42bae2d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/42bae2d795fda5514475fc5dbec449182d64e1e8)) +* **thing_helper:** Query now go through search ([03cebb9](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/03cebb9fb73d5cd2033c444c54537b9b4f560615)) +* **transfers:** add permissions transfer functionality and update configuration ([de1ace4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/de1ace455dda61ba931fdfbe0a51518bfa9e1d56)) +* **transfers:** add permissions transfer functionality and update configuration ([563d5d1](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/563d5d1128791cdaf1d3428a0ee7ed9e161df1d5)) +* **transfers:** add permissions transfer functionality and update configuration ([7a51c38](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7a51c38596b7431e97bc770de05cd1ca2e7b6f13)) +* **transfers:** add support for legacy SiteNotes handling ([a58fc75](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a58fc7543af4f54ae41e61e628694525a342f798)) +* update API endpoint paths from /oapi to /ogcapi and set default session secret key for test environments ([c799ed5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c799ed5c50732e8413d890619ae53e6226a6e660)) +* update app.template.yaml to use block scalar for sensitive environment variables ([0c904f6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0c904f61b63295cbb57241350a485929661a54e3)) +* update database service configuration in tests to use development setup ([ae1ce5e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ae1ce5ed280733e87b8d3aa87d2c62a8029565c7)) +* update dotenv loading behavior to prevent overriding existing environment variables ([f001b58](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/f001b5839c1f72ef496c46ef816cf75d157c6c80)) +* update endpoint paths from /oapi to /ogcapi and improve pg_cron availability checks ([36b5bb5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/36b5bb5d053149add9b112e42201c144efcab695)) +* update environment configuration for Docker and enhance README with local development setup ([8e9f4e6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8e9f4e61bac39f6f265432f56ebec5da4daebca4)) +* update environment variable references for PostgreSQL settings in configuration files ([b356c7a](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b356c7a371dfcad367151b8b90ea0ea1a6eb7a0d)) +* update latest TDS materialized view to use observation datetime and add tests for timestamp accuracy ([cbd7449](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/cbd7449b8d90a5f4ca9dfe129f3bae5d1385f8dc)) +* update nullable fields in relaxed_constraints.md for MeasuringPointHistory and remove depth validation ([e089b32](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e089b32a93556fb7a24f9cfbe0226d0b873f5806)) +* update OGC API endpoint path and add unique indexes for materialized views ([7c9bd47](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7c9bd47feab4761a5f6bed12d0d295995d7b7411)) +* update package versions in requirements.txt for compatibility and stability ([cc3f904](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/cc3f904682a2f0e6a1b270272f89fb3035997c0f)) +* update package versions in requirements.txt for compatibility and stability ([8f6212c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8f6212c075e6a5911913428fdd0f90c1a9512657)) +* update pygeoapi configuration to use environment variables for PostgreSQL settings ([77968de](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/77968dea0372c12d143d96d780288cf74fb86297)) +* update pygeoapi supporting views and enhance thing collections for groundwater monitoring ([d4da8ff](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d4da8ff75e4488ae8f8edb42adf90ee06342a7e0)) +* update SQL queries in ngwmn_helper.py for consistent quoting and improved readability ([6b493c4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/6b493c43ad224855c09d025de9ab503023dcb562)) +* update SQL queries in ngwmn_helper.py for consistent quoting and improved readability ([36e4fad](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/36e4fad035d16b4a244e19212071c1eac0aa5450)) +* update test configuration to use specific PostGIS version and improve database readiness checks ([4312676](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/43126766a7a7e8c59bb2ee35aac5d2465603969b)) +* update test configuration to use specific PostGIS version and improve database readiness checks ([3b5af5a](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3b5af5aaaeaa756c86abaae297d8a1c1e3f4042e)) +* update well response validation and enhance type safety for contact fields ([14e9066](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/14e9066267cc2e3575d3c953d3e18727b0316d76)) +* **water-level-csv:** warn when uploaded mp height differs from well history ([a2584c9](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a2584c98921a03bf86261a1b77c812a657b46499)) +* **water-level-import:** add best-effort row savepoints ([2f2f923](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2f2f923daa61575f85d738156bb05d9d12d14780)) +* **water-level-import:** add idempotent groundwater persistence ([6a09881](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/6a09881afc9b0107caffe3915577c5cdd02bf5ba)) +* **water-level-import:** align partial-success API and CLI behavior ([4c3eea0](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4c3eea000a338e5031bb0296fc26ea94b35865a3)) +* **water-level-import:** enhance validation and resolve mp_height ([eccf31f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/eccf31f9bf15735757ad8303e36dfb3c11e1a50f)) +* **water-level-importer:** add support for field event participants and sampler validation ([9ab9846](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/9ab9846a0e3ab8b24021bd0ec67d19f3d5ea82c2)) +* **water-level-import:** normalize standalone CSV schema ([53fee18](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/53fee18538d3cfa1cc821d850afbe45c32e83f86)) +* **well inventory:** add groundwater level field activity for well inventory import ([1dfc24d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1dfc24dce393d026e6b44bf63c88291f8981d774)) +* **well inventory:** require measuring_point_height_ft or mp_height_ft for non-null observations ([fe7fba2](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fe7fba2558b1a4d17aa13c23ddda9d45fc799d11)) +* **well-inventory:** emit per-row progress during imports ([0ec4da9](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0ec4da9f1c2bc991c85fa8dc7d307a26f1026738)) + + +### Bug Fixes + +* add imports for shapely and sqlalchemy to support database operations ([8896bb6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8896bb67a4d97e50ab77621bb015a6cd7eac7f5a)) +* add new well status term and normalize completion date handling ([04c943e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/04c943e61a60ab7cc40264788443ea6a0da88d68)) +* add unmatched locations to the import process and update test assertions ([2dd6628](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2dd6628dacd34f1a214b49982aea6264ed90a639)) +* **cli:** handle UTF-8 BOM in CSV decoding for well inventory import ([a7bad53](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a7bad5305da4a96477317f46f75093d1b72c4fcd)) +* **cli:** include staged sql path in local db restore result ([b80bd32](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b80bd32d5084f8c138db92af71a668f005c659cd)) +* **contact:** Make contact role and type non-nullable ([e768d8a](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e768d8aa36717b25ae959cd49fcede04514831d0)) +* **contacts:** allow nullable role and contact_type in well inventory import ([86aa582](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/86aa582fccc2f75dfd87f0fbafa91b932cad7d8b)) +* correct logic for recording interval check in sensor_transfer.py ([066ab6a](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/066ab6a4f693b76f29744311a2d721d07d7fbdfe)) +* correct logic for recording interval check in sensor_transfer.py ([ee8d8db](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ee8d8db26f0b55b45ec1bf120c389656cd79699f)) +* **db:** remove unique constraint on MinorTraceChemistry table ([1d9fbc5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1d9fbc5f736f248398aabd35cfa9776b35039c78)) +* **db:** skip null measuring point history in property calculations ([5be435f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/5be435fa78a3bd15c6fb8e6528e7df613f96b1e5)) +* **db:** update import logic to use `nma_GlobalID` for MinorTraceChemistry records ([8c26bc1](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8c26bc154212008922e73c8edab8efe09f346b81)) +* enforce required thing_id for NMA_SurfaceWaterData and add validation ([ba7881b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ba7881bccf444a643ac5aae17a38c5e2597e5d63)) +* enhance autogen value handling with regex validation ([5338013](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/5338013dace1239125716ee74e11a86701b77b19)) +* enhance completion date normalization to handle various input types and improve error reporting ([1260784](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1260784f9a1243938af1bc452ec0e9dbc69a6ff3)) +* enhance contact name generation logic to use OwnerKey as fallback and add deduplication handling ([727bea1](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/727bea11151eb90d53a679e2c4ebdb7ddbd87c88)) +* enhance error handling and validation reporting in CSV upload process ([f8ceb2c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/f8ceb2caa2a6c9ba276abf6b906351ed4b5dace9)) +* enhance name generation logic to use OwnerKey as fallback ([596f37f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/596f37f3c15b22459218bc5ccf0fb478ed3ff89b)) +* enhance name generation logic to use OwnerKey as fallback and update transfer method to parallel execution ([b5f84ad](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b5f84ad246da6b8f5a32f6ddb0dcf0d75900fc94)) +* **importers:** prevent duplicate well-name collisions during CSV imports ([7f83e4c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7f83e4c83121fce58eb0d81ade65aeb780217a9b)) +* improve error handling and logging for recording interval estimation ([dcd49b4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/dcd49b40c622e31e6539998429e02c07c85a8c60)) +* improve error handling and logging for recording interval estimation ([3c11d05](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3c11d0592792e65d914d013c8140eed7e2ee5b62)) +* improve error handling and logging for recording interval estimation feat: add auto-generation prefix extraction for well IDs with new regex support ([db0dc8f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/db0dc8fa5f394cf540e50a00177823c7534f6e35)) +* initialize test schema and update alembic configuration handling ([52a94ec](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/52a94eca7f1905b5518d84a4c7d96747f9bacbc5)) +* log BackfillResult outcomes and preserve tracebacks in orchestrator ([a4edbf7](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a4edbf720256e8b370de1d22505d02ccbd5adc74)), closes [#558](https://github.com/DataIntegrationGroup/OcotilloAPI/issues/558) +* make pg_cron optional for local development ([e4cd4e3](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e4cd4e32d836a9c89ad5c1a81909918b10bd9cda)), closes [#576](https://github.com/DataIntegrationGroup/OcotilloAPI/issues/576) +* move pre-commit/pytest dev tools out of prod deps (unblock deploy) ([#705](https://github.com/DataIntegrationGroup/OcotilloAPI/issues/705)) ([3892047](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/389204756c995f3b333ee478afc53a3deb6e63d8)) +* pin joserfc in requirements.txt to unblock deploy ([#704](https://github.com/DataIntegrationGroup/OcotilloAPI/issues/704)) ([018cc44](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/018cc44c80914ac3af3f6210fa99dce5cd9a2cff)) +* remove unsupported pattern handling in well_inventory_csv.py ([08c4beb](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/08c4beb2c3d894e10b118bb6007caa3b205e2c75)) +* remove unsupported pattern handling in well_inventory_csv.py ([d95904b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d95904bd76be699c3f80fa02b3c4cab87112c12b)) +* remove unused `as exc` binding ([c77f598](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c77f598f3801c07aaec8c2a79dcb3c0b42c5c40c)) +* **schemas/well_inventory:** Add case for None ([90b5b8a](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/90b5b8a33e43f8934febb59e9dcd399f6930a25f)) +* **schemas:** fix well inventory schema mismatch for `SampleMethod` and `DataQuality` ([9742c03](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/9742c030a0ebdb0dd87373afdecc7864b985d949)) +* **schemas:** Swap before mode for after in well_inventory & water_level_csv ([39e01de](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/39e01de7cb3ab8613d4e3973c81fb6e30c8673e2)) +* **services/util:** Add needed import ([772e326](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/772e326aef65461a04d3f737ec28ad72312b4640)) +* **services/util:** Mv normalize_datetime_to_utc() to services/util & used it in well_inventory ([df0180d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/df0180d294c35b320eeadf8df0a1eefe024238a5)) +* **test:** clarify docstrings ([6df12f6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/6df12f6e70f86286de01c244cdb20022cf0bd86a)) +* **test:** clarify docstrings ([4a0f0da](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4a0f0daf8eabfd8785924f9742ffe2bff8c26cdc)) +* **test:** compare dt aware objects for optional water level tests ([fe9fc0d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fe9fc0ddccf514bc6cef16a4349e147516003bb1)) +* **test:** encore ocotilloapi_test for bdd tests ([1e0fd84](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1e0fd843795240138f6858b1b10ff41c6175982d)) +* **test:** ensure different step test names ([0fada74](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0fada745287ef767646d69856417585ba7cb4cf0)) +* **test:** ensure sample references correct field activity ([e899412](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e899412b4f5d038f2c2fd69f11c767e932c8a4af)) +* **test:** fix failing well inventory tests ([3e9dcf3](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3e9dcf39f228065a71beedfdda6467271cfe5720)) +* **test:** fix typo in doc string ([cf7ca5a](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/cf7ca5a27c5018f781452abe1ab66b25469e8038)) +* **test:** make docstring more accurate ([b6e5d80](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b6e5d800433de1d630e5c014e3766bf8b5b72c36)) +* **test:** make test name more accurate ([1763df2](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1763df28badc5e1043e7034d4e4f7db483b52ee1)) +* **test:** remove print debugging statement ([a0ea88d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a0ea88d8c355113d445f224d497be1caf18d1ef7)) +* **test:** use enums when testing helper functions ([0c9e8fa](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0c9e8faeada23c4a5e2b7eb194d5fbbd18336351)) +* **test:** utilize autouse fixture to clean up tests ([815cfc6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/815cfc62a94aaba194fec042dff402e6e357d590)) +* **transfers:** handle duplicate legacy SiteNotes with date context ([e6a8f21](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e6a8f2176de3805d19050bc24f1c760621972e5d)) +* **transfers:** handle missing MPHeight values during migration ([9f20199](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/9f2019963927969105c0114d8337746c314a7fc8)) +* update pygeoapi configuration to use environment variable for PostgreSQL password ([d635162](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d635162a8dee994df4e53ee81a340bcf7fd637bd)) +* update references from NMSampleLocations to OcotilloAPI in admin views and documentation ([7a90734](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7a90734893c97391c53acc443a1205ce38f55f35)) +* update type hint for well_id parameter in _extract_autogen_prefix function ([729faba](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/729faba7f41608d7400d4c10944be5303c89c7ce)) +* validate thing_id before GCS upload to prevent orphaned blobs ([#698](https://github.com/DataIntegrationGroup/OcotilloAPI/issues/698)) ([fb847f8](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fb847f8486e176e89d397bbf60137db05cc1ae8a)) +* **water-level-importer:** reuse contacts by unique name and organization ([56c96a8](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/56c96a8615ccbd9f7e605624bb778036981386ce)) +* **water-level-import:** handle savepoint initialization failure gracefully ([e0d7e45](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e0d7e453187fc788f9a6c304cbb1e53ea4b1a807)) +* **water-level-import:** harden real-file import cleanup ([fa257cd](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fa257cd0047a52aeb3f4e0fd9866eb0d6504ff04)) +* **water-level-import:** refine handling of alias and canonical headers in CSV processing ([623206e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/623206ed4729789de13a197d988da8e0c17ac029)) +* **water-level-import:** skip persistence when no valid rows exist ([9741d41](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/9741d41c43feac59e045b0aaa0e2e847461cd97a)) +* **well inventory:** allow null mp heights ([a01e091](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a01e0915c25df36a8e046707531f6436499b1d04)) +* **well inventory:** check for Nones to avoid truthiness traps ([aac5c95](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/aac5c9521d4f8d1d7eaf84a139cebd17d78cb56b)) +* **well inventory:** extract role/contact_type from enum ([0a30676](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0a306766b06490e34e154aec4601dad17418fc11)) +* **well inventory:** retrieve groundwater level reason enum value, else None ([b2bc17d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b2bc17dfec99e3c17625b20865fde049126f9044)) +* **well inventory:** test if mp height not None to avoid truthiness trap ([4c0db46](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4c0db46958cabd0d86fe0ae7c04abf45cd292650)) +* **well inventory:** use correct activity type for water level records ([d6e1dc4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d6e1dc4c56a3fb8fad6396a92b0b0af71e355acf)) +* **well inventory:** use one mp height for thing and gwl ([6d03bf4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/6d03bf42452ff72edc02314d1366245e20664cb9)) +* **well_inventory:** date_time was always returning None ([3cbb0e5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3cbb0e59758f4e4857cd83d47250ec2d8d3c645a)) +* **well-inventory:** avoid creating empty project groups on failed imports ([1460d4f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1460d4f461876049c8534d81b5f3c7820c6f9cf2)) +* **well-inventory:** improve error handling for database exceptions ([4f2b3cd](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4f2b3cd1182cae757b6a3c1a57312436938803c2)) +* **well-inventory:** normalize "Complete" monitoring frequency to "Not currently monitored" ([f482b5a](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/f482b5ab85539db2c21eb9bc46404f3ce50dfb2f)) +* **well-inventory:** normalize blank contact values and add missing organization terms ([b2df9ab](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b2df9ab6fcd49db9dd2e08d99a6358ca5d1c89fb)) +* **well-inventory:** preserve attempted water-level records when depth-to-water is blank ([6d2d810](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/6d2d81096c2fd1c2dd829e603895ed0c2e770432)) +* **well-inventory:** stop defaulting missing observation data quality to Unknown ([5fabcd1](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/5fabcd11ed441f77aed0727e76ec5b8a92ad385f)) +* **well-inventory:** treat whitespace-only lexicon values as blank ([3e93bb6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3e93bb644f676e127f5d5b376f24d50ce9915fe2)) +* **well-inventory:** validate invalid well_hole_status before persistence ([2932721](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2932721a68b91a8f68daa7c3dfbcd40c1dd310b3)) +* **well-transfer:** defer WellTransferer external I/O until needed ([8490550](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8490550ec0f850c6743c02e4390148add51848c0)) +* **well-transfer:** exclude monitoring_status from Thing creation ([83bc1d9](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/83bc1d94923593870b2bf28b8b7f03b769789b94)) +* **well-transfer:** improve aquifer persistence with nested savepoints and better error handling ([81a016c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/81a016c08ef866336068457d68dfa3df454ec4c7)) +* **well-transfer:** preload measuring point estimator before parallel workers ([91aaaf4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/91aaaf418031de7e73b519d133cfbe9362ebb850)) +* **well-transfer:** preload shared elevation cache before parallel workers ([fbdc18b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fbdc18b4044ea23bba5926a8fbb8244e55f6b1e1)) From 25c22800c9a3e6032f6374d70850c90b5b1c432d Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Wed, 10 Jun 2026 15:45:14 -0600 Subject: [PATCH 026/160] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 270193b5e..51bda31fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,7 @@ ### Features * add app.template.yaml for dynamic app configuration and update CI/CD scripts to render app.yaml ([9274d83](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/9274d830a464215286f4c8c470ca87f54f5f8ebf)) -* add auto-generation prefix extraction for well IDs with new regex support ([dcd49b4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/dcd49b40c622e31e6539998429e02c07c85a8c60)) -* add auto-generation prefix extraction for well IDs with new regex support ([3c11d05](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3c11d0592792e65d914d013c8140eed7e2ee5b62)) +* add auto-generation prefix extraction for well IDs with new regex support ([dcd49b4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/dcd49b40c622e31e6539998429e02c07c85a8c60)) ([3c11d05](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3c11d0592792e65d914d013c8140eed7e2ee5b62)) * add command to import project area boundaries and create associated OGC view ([919222e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/919222e81c47fa29c6dbfc2920412a70fb3903f0)) * add Dockerfile to set up PostGIS with pg_cron for scheduled tasks ([dc424cf](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/dc424cf0fde24bda9957ee5b7e236287aa464979)) * add field event limit to well details API and enhance response validation ([ac76eba](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ac76eba0cf1430fd9ec147be34063d747ff933f7)) From 27751110305687086de190ca342ce8c9743192e8 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 10 Jun 2026 15:48:47 -0600 Subject: [PATCH 027/160] chore: sync uv.lock to released version 1.1.0 release-please bumps pyproject.toml but not uv.lock; tests run uv sync --locked which asserts the lockfile is unchanged. Co-Authored-By: Claude Fable 5 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index e1ac33e08..90b0049b3 100644 --- a/uv.lock +++ b/uv.lock @@ -1489,7 +1489,7 @@ wheels = [ [[package]] name = "ocotilloapi" -version = "1.0.0" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From 4587fdb398a0ca8c1e3366ac3bc6d4fd936408bb Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 10 Jun 2026 16:23:15 -0600 Subject: [PATCH 028/160] ci: sync uv.lock in forward-merge PRs after release version bump release-please bumps the project version in pyproject.toml when cutting releases on production and hotfix/v* branches, but never updates uv.lock, which records the editable ocotilloapi package version. tests.yml runs `uv sync --locked`, so every back-merge and hotfix forward-merge PR failed CI until someone manually ran `uv lock` (see 27751110 for PR #714). - back-merge-to-staging: after the manifest sync, install uv, run `uv lock`, and commit the lockfile to the merge branch if it changed. - forward-merge-to-production: the PR head is the hotfix branch itself, so check out the hotfix branch explicitly, run `uv lock`, and push a sync commit directly to it before opening the PR. Plain push (not force) so an out-of-date checkout fails loudly. Both steps are idempotent: lockfile already in sync -> no commit, no push. Existing-PR checks and --force-with-lease behavior unchanged. Co-Authored-By: Claude Fable 5 --- .github/workflows/forward-merge.yml | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml index 9567229a8..0d0b0490d 100644 --- a/.github/workflows/forward-merge.yml +++ b/.github/workflows/forward-merge.yml @@ -97,6 +97,25 @@ jobs: git commit -m "chore: sync staging release-please manifest to ${TAG}" fi + # release-please bumps the project version in pyproject.toml but never + # touches uv.lock, which records the editable ocotilloapi version. + # tests.yml runs `uv sync --locked`, so the back-merge PR fails CI until + # the lockfile is re-locked (see commit 27751110). Idempotent: no + # lockfile change -> no commit. + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Sync uv.lock to released version + run: | + uv lock + if ! git diff --quiet -- uv.lock; then + git add uv.lock + git commit -m "chore: sync uv.lock to released version ${TAG#v}" + fi + # Retry-safe: skip if a PR is already open for this merge; force-with-lease # handles a branch left behind by a previous partial run. - name: Push branch and open PR @@ -131,6 +150,35 @@ jobs: SOURCE: ${{ inputs.source_branch }} steps: - uses: actions/checkout@v6.0.3 + with: + ref: ${{ inputs.source_branch }} + token: ${{ secrets.FORWARD_MERGE_TOKEN || github.token }} + + - name: Set up git user + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # The PR head is the hotfix branch itself, so the lockfile sync commit + # (release-please bumped pyproject.toml but not uv.lock; tests run + # `uv sync --locked`) is pushed directly to the hotfix branch before the + # PR is opened. Idempotent: lockfile already in sync -> no commit, no + # 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.2.0 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Sync uv.lock to released version + run: | + uv lock + if ! git diff --quiet -- uv.lock; then + git add uv.lock + git commit -m "chore: sync uv.lock to released version ${TAG#v}" + git push origin "HEAD:${SOURCE}" + fi # Retry-safe: skip if a PR is already open from this hotfix branch. - name: Open PR hotfix -> production From 053ab9392156157240a7efea619f1a691cf42d51 Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Thu, 11 Jun 2026 09:25:34 -0600 Subject: [PATCH 029/160] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/forward-merge.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml index 0d0b0490d..024e07c05 100644 --- a/.github/workflows/forward-merge.yml +++ b/.github/workflows/forward-merge.yml @@ -151,9 +151,9 @@ jobs: steps: - uses: actions/checkout@v6.0.3 with: + fetch-depth: 0 ref: ${{ inputs.source_branch }} token: ${{ secrets.FORWARD_MERGE_TOKEN || github.token }} - - name: Set up git user run: | git config user.name "github-actions[bot]" From 621fd12f6d39ea1a9d6698fe8ada5bff190a6694 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 11 Jun 2026 16:46:17 -0400 Subject: [PATCH 030/160] BDMS-897: Add feedback endpoint for bug reports and feature requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /feedback, which accepts a bug report or feature request from any authenticated user and does two things: 1. Creates a Jira issue in the BDMS project using the Jira API v3 2. Optionally posts a Slack notification if SLACK_FEEDBACK_WEBHOOK_URL is set Bug reports create a Jira Bug issue with severity and a link to the page where the report was filed. Feature requests create a Jira Task with the problem description, intended audience, and expected behaviour. Slack notification is best-effort — if it fails the HTTP response still returns the Jira key and URL so the UI can confirm the report was filed. Three env vars are required (JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN). JIRA_DEFAULT_PROJECT defaults to BDMS. SLACK_FEEDBACK_WEBHOOK_URL is optional and will be provided separately. --- .env.example | 8 ++ api/feedback.py | 267 +++++++++++++++++++++++++++++++++++++++++++ core/initializers.py | 2 + 3 files changed, 277 insertions(+) create mode 100644 api/feedback.py diff --git a/.env.example b/.env.example index 3f835882e..27f624d4c 100644 --- a/.env.example +++ b/.env.example @@ -62,3 +62,11 @@ 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 +JIRA_EMAIL=your_jira_email +JIRA_API_TOKEN=your_jira_api_token +JIRA_DEFAULT_PROJECT=BDMS +# Optional — Slack notifications are skipped if this is blank +SLACK_FEEDBACK_WEBHOOK_URL= diff --git a/api/feedback.py b/api/feedback.py new file mode 100644 index 000000000..68f632b2f --- /dev/null +++ b/api/feedback.py @@ -0,0 +1,267 @@ +import os +from datetime import datetime, timezone +from typing import Literal + +import httpx +from fastapi import APIRouter +from pydantic import BaseModel + +from core.dependencies import viewer_dependency + +router = APIRouter(prefix="/feedback", tags=["feedback"]) + + +class FeedbackCreate(BaseModel): + type: Literal["bug", "feature"] + page_url: str + reporter_name: str | None = None + reporter_email: str | None = None + browser: str | None = None + submitted_at: str | None = None + # Bug fields + what_happened: str | None = None + severity: str = "Low" + # Feature fields + problem: str | None = None + who_would_use: str | None = None + what_it_should_do: str | None = None + + +class FeedbackResponse(BaseModel): + jira_key: str + jira_url: str + + +def _build_jira_payload(payload: FeedbackCreate) -> dict: + project = os.environ.get("JIRA_DEFAULT_PROJECT", "BDMS") + + reporter_line = payload.reporter_name or payload.reporter_email or "Unknown" + submitted = payload.submitted_at or datetime.now(timezone.utc).strftime( + "%Y-%m-%d %H:%M UTC" + ) + + context_items = [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": f"Page: {payload.page_url}"}], + } + ], + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": f"Reported by: {reporter_line}"} + ], + } + ], + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": f"Browser: {payload.browser or 'Unknown'}", + } + ], + } + ], + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": f"Submitted: {submitted}"}], + } + ], + }, + ] + + if payload.type == "bug": + summary = f"Bug: {(payload.what_happened or '')[:80].strip()}" + issue_type = "Bug" + body_content = [ + { + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "What happened"}], + }, + { + "type": "paragraph", + "content": [{"type": "text", "text": payload.what_happened or ""}], + }, + { + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "Severity"}], + }, + { + "type": "paragraph", + "content": [{"type": "text", "text": payload.severity}], + }, + ] + priority_map = {"Low": "Low", "Medium": "Medium", "High": "High"} + priority = priority_map.get(payload.severity, "Medium") + else: + summary = f"Feature request: {(payload.problem or '')[:80].strip()}" + issue_type = "Task" + body_content = [ + { + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "What problem does this solve?"}], + }, + { + "type": "paragraph", + "content": [{"type": "text", "text": payload.problem or ""}], + }, + { + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "Who would use this?"}], + }, + { + "type": "paragraph", + "content": [ + {"type": "text", "text": payload.who_would_use or "Not specified"} + ], + }, + { + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "What should it do?"}], + }, + { + "type": "paragraph", + "content": [{"type": "text", "text": payload.what_it_should_do or ""}], + }, + ] + priority = "Medium" + + description = { + "type": "doc", + "version": 1, + "content": [ + *body_content, + { + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "Context"}], + }, + {"type": "bulletList", "content": context_items}, + ], + } + + return { + "fields": { + "project": {"key": project}, + "issuetype": {"name": issue_type}, + "summary": summary, + "description": description, + "priority": {"name": priority}, + } + } + + +def _build_slack_payload(payload: FeedbackCreate, jira_key: str, jira_url: str) -> dict: + reporter = payload.reporter_name or payload.reporter_email or "Unknown" + submitted = payload.submitted_at or datetime.now(timezone.utc).strftime( + "%Y-%m-%d %H:%M UTC" + ) + + if payload.type == "bug": + header = f"🐛 Bug report — {jira_key}" + description_text = payload.what_happened or "" + severity_field = {"type": "mrkdwn", "text": f"*Severity:*\n{payload.severity}"} + extra_fields = [severity_field] + else: + header = f"💡 Feature request — {jira_key}" + description_text = payload.problem or "" + extra_fields = [] + if payload.who_would_use: + extra_fields.append( + { + "type": "mrkdwn", + "text": f"*Who would use this:*\n{payload.who_would_use}", + } + ) + + blocks = [ + {"type": "header", "text": {"type": "plain_text", "text": header}}, + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": f"*Reporter:*\n{reporter}"}, + {"type": "mrkdwn", "text": f"*Submitted:*\n{submitted}"}, + {"type": "mrkdwn", "text": f"*Page:*\n{payload.page_url}"}, + *extra_fields, + ], + }, + { + "type": "section", + "text": {"type": "mrkdwn", "text": description_text[:2900]}, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"<{jira_url}|View {jira_key} in JIRA →>", + }, + }, + ] + + return {"text": header, "blocks": blocks} + + +@router.post("", response_model=FeedbackResponse) +async def create_feedback( + payload: FeedbackCreate, + _user=viewer_dependency, +): + jira_base = os.environ["JIRA_BASE_URL"] + jira_email = os.environ["JIRA_EMAIL"] + jira_token = os.environ["JIRA_API_TOKEN"] + + async with httpx.AsyncClient() as client: + jira_resp = await client.post( + f"{jira_base}/rest/api/3/issue", + json=_build_jira_payload(payload), + auth=(jira_email, jira_token), + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + }, + timeout=15, + ) + jira_resp.raise_for_status() + jira_data = jira_resp.json() + + jira_key = jira_data["key"] + jira_url = f"{jira_base}/browse/{jira_key}" + + slack_webhook = os.environ.get("SLACK_FEEDBACK_WEBHOOK_URL") + if slack_webhook: + try: + async with httpx.AsyncClient() as client: + await client.post( + slack_webhook, + json=_build_slack_payload(payload, jira_key, jira_url), + timeout=10, + ) + except Exception: + # Slack notification is best-effort — don't fail the request if it errors + pass + + return FeedbackResponse(jira_key=jira_key, jira_url=jira_url) + + +# ============= EOF ============================================= diff --git a/core/initializers.py b/core/initializers.py index 98da4e8ee..356005d80 100644 --- a/core/initializers.py +++ b/core/initializers.py @@ -216,6 +216,7 @@ def register_api_routes(app): from api.search import router as search_router from api.geospatial import router as geospatial_router from api.ngwmn import router as ngwmn_router + from api.feedback import router as feedback_router app.include_router(asset_router) app.include_router(author_router) @@ -231,6 +232,7 @@ def register_api_routes(app): app.include_router(search_router) app.include_router(thing_router) app.include_router(ngwmn_router) + app.include_router(feedback_router) add_pagination(app) app.state.api_routes_registered = True From 6ecee1c365b3aeb71843c080c4c4cebe7f7bb524 Mon Sep 17 00:00:00 2001 From: jross Date: Thu, 11 Jun 2026 15:34:51 -0600 Subject: [PATCH 031/160] feat: add NGWMN views sourced from new Ocotillo data model Add view_NGWMN_WaterLevels, view_NGWMN_WellConstruction, and view_NGWMN_Lithology as PostgreSQL views over the new Ocotillo tables, replicating the legacy AMPAPI (NM_Aquifer) view definitions so NGWMN exports no longer depend on the static NMA_view_NGWMN_* copy tables. Co-Authored-By: Claude Fable 5 --- ...2z3_add_ngwmn_views_from_ocotillo_model.py | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 alembic/versions/u8v9w0x1y2z3_add_ngwmn_views_from_ocotillo_model.py diff --git a/alembic/versions/u8v9w0x1y2z3_add_ngwmn_views_from_ocotillo_model.py b/alembic/versions/u8v9w0x1y2z3_add_ngwmn_views_from_ocotillo_model.py new file mode 100644 index 000000000..d878e36f9 --- /dev/null +++ b/alembic/versions/u8v9w0x1y2z3_add_ngwmn_views_from_ocotillo_model.py @@ -0,0 +1,205 @@ +"""add NGWMN views sourced from the new Ocotillo data model + +Replaces the legacy NMA_view_NGWMN_* copy tables as the source for NGWMN +exports. These views reproduce the original AMPAPI (SQL Server) view +definitions but read from the new Ocotillo tables: + +- view_NGWMN_WaterLevels: observation/sample/field_activity/field_event/thing +- view_NGWMN_WellConstruction: thing/well_screen/well_casing_material +- view_NGWMN_Lithology: thing_geologic_formation_association/geologic_formation + +Revision ID: u8v9w0x1y2z3 +Revises: t6u7v8w9x0y1 +Create Date: 2026-06-11 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "u8v9w0x1y2z3" +down_revision: Union[str, Sequence[str], None] = "t6u7v8w9x0y1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +REQUIRED_TABLES = { + "thing", + "well_screen", + "well_casing_material", + "observation", + "sample", + "field_activity", + "field_event", + "parameter", + "thing_geologic_formation_association", + "geologic_formation", +} + +DROP_WATERLEVELS_SQL = 'DROP VIEW IF EXISTS "view_NGWMN_WaterLevels"' +DROP_WELLCONSTRUCTION_SQL = 'DROP VIEW IF EXISTS "view_NGWMN_WellConstruction"' +DROP_LITHOLOGY_SQL = 'DROP VIEW IF EXISTS "view_NGWMN_Lithology"' + + +def _create_waterlevels_view() -> str: + # Mirrors dbo.view_NGWMN_WaterLevels: + # SELECT PointID, DateMeasured, DepthToWaterBGS, 'ft bgs', CASE + # MeasurementMethod..., CASE DataQuality..., PublicRelease + # FROM WaterLevels WHERE PublicRelease = 1 + # + # The transfer stored observation.value as depth-to-water below the + # measuring point (DepthToWater) and measuring_point_height separately, + # so DepthToWaterBGS = value - measuring_point_height (0 when no MP + # height was recorded). + # + # observation_datetime was converted to UTC during transfer; rows whose + # legacy timestamp had no time component were stored at 00:00 UTC, so + # only rows with a real time component are shifted back to local time + # before taking the date. + # + # MeasurementMethod/WLAccuracy reproduce the legacy CASE expressions, + # keyed on the lexicon meanings the transfer wrote (LU_MeasurementMethod + # and LU_DataQuality), including the legacy quirk mapping code O + # ("Observed...") to 'Acoustic Sounder'. + return """ + CREATE VIEW "view_NGWMN_WaterLevels" AS + SELECT + t.name AS "PointID", + CASE + WHEN (o.observation_datetime AT TIME ZONE 'UTC')::time = '00:00:00' + THEN (o.observation_datetime AT TIME ZONE 'UTC')::date + ELSE (o.observation_datetime AT TIME ZONE 'America/Denver')::date + END AS "DateMeasured", + o.value - COALESCE(o.measuring_point_height, 0) AS "DepthToWaterBGS", + 'ft bgs' AS "WLUnits", + CASE s.sample_method + WHEN 'Steel-tape measurement' THEN 'Steel tape' + WHEN 'Electric tape measurement (E-probe)' THEN 'Electric tape' + WHEN 'Observed (required for F, N, and W water level status)' THEN 'Acoustic Sounder' + WHEN 'Estimated' THEN 'Estimated' + WHEN 'Reported, method not known' THEN 'Reported' + WHEN 'Pressure-gage measurement' THEN 'Pressure gauge' + WHEN 'Unknown (for legacy data only; not for new data entry)' THEN 'Unknown; from legacy data' + ELSE NULL + END AS "MeasurementMethod", + CASE o.nma_data_quality + WHEN 'Water level accurate to within two hundreths of a foot' THEN '0.02 ft' + WHEN 'Water level accurate to within one foot' THEN '1.0 ft' + WHEN 'Water level accuracy not to nearest foot or water level not repeatable' THEN 'Unknown' + ELSE NULL + END AS "WLAccuracy", + TRUE AS "PublicRelease" + 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 + JOIN parameter AS p ON p.id = o.parameter_id + WHERE p.parameter_name = 'groundwater level' + AND o.release_status = 'public' + """ + + +def _create_wellconstruction_view() -> str: + # Mirrors dbo.view_NGWMN_WellConstruction: + # WellData LEFT JOIN WellScreens ON WellData.WellID = WellScreens.WellID + # CasingTop is 0 whenever a casing depth exists (casing assumed to start + # at ground surface). The legacy free-text CasingDescription was reduced + # to controlled material terms during transfer, so it is rebuilt here as + # a comma-separated list of well_casing_material terms. + return """ + CREATE VIEW "view_NGWMN_WellConstruction" AS + SELECT + t.name AS "PointID", + CASE WHEN t.well_casing_depth IS NOT NULL THEN 0::double precision END AS "CasingTop", + t.well_casing_depth AS "CasingBottom", + CASE WHEN t.well_casing_depth IS NOT NULL THEN 'ft bgs' END AS "CasingDepthUnits", + ws.screen_depth_top AS "ScreenTop", + ws.screen_depth_bottom AS "ScreenBottom", + CASE WHEN ws.screen_depth_bottom IS NOT NULL THEN 'ft bgs' END AS "ScreenBottomUnit", + ws.screen_description AS "ScreenDescription", + cm.materials AS "CasingDescription" + FROM thing AS t + LEFT JOIN well_screen AS ws ON ws.thing_id = t.id + LEFT JOIN LATERAL ( + SELECT string_agg(wcm.material, ', ' ORDER BY wcm.material) AS materials + FROM well_casing_material AS wcm + WHERE wcm.thing_id = t.id + ) AS cm ON TRUE + WHERE t.thing_type = 'water well' + """ + + +def _create_lithology_view() -> str: + # Mirrors dbo.view_NGWMN_Lithology: + # Stratigraphy INNER JOIN LU_Lithology ON Lithology = ABBREVIATION + # The new model keeps the resolved lithology term on geologic_formation + # (the abbreviation code was not migrated), so the term backs both the + # Lithology and TERM columns. The inner join is reproduced by requiring + # a non-null lithology. StratSource was not migrated and is NULL. + return """ + CREATE VIEW "view_NGWMN_Lithology" AS + SELECT + tgfa.id AS "OBJECTID", + t.name AS "PointID", + gf.lithology AS "Lithology", + gf.lithology AS "TERM", + NULL::character varying AS "StratSource", + tgfa.top_depth AS "StratTop", + CASE WHEN tgfa.top_depth IS NOT NULL THEN 'ft bgs' END AS "StratTopUnit", + tgfa.bottom_depth AS "StratBottom", + CASE WHEN tgfa.bottom_depth IS NOT NULL THEN 'ft bgs' END AS "StratBottomUnit" + FROM thing_geologic_formation_association AS tgfa + JOIN thing AS t ON t.id = tgfa.thing_id + JOIN geologic_formation AS gf ON gf.id = tgfa.geologic_formation_id + WHERE gf.lithology IS NOT NULL + """ + + +def upgrade() -> 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 NGWMN views. Missing required tables: " + f"{', '.join(sorted(missing))}" + ) + + op.execute(text(DROP_WATERLEVELS_SQL)) + op.execute(text(_create_waterlevels_view())) + op.execute( + text( + 'COMMENT ON VIEW "view_NGWMN_WaterLevels" IS ' + "'Public manual groundwater level measurements in the NGWMN " + "exchange format, sourced from the Ocotillo observation model.'" + ) + ) + + op.execute(text(DROP_WELLCONSTRUCTION_SQL)) + op.execute(text(_create_wellconstruction_view())) + op.execute( + text( + 'COMMENT ON VIEW "view_NGWMN_WellConstruction" IS ' + "'Well casing and screen intervals in the NGWMN exchange format, " + "sourced from the Ocotillo thing/well_screen model.'" + ) + ) + + op.execute(text(DROP_LITHOLOGY_SQL)) + op.execute(text(_create_lithology_view())) + op.execute( + text( + 'COMMENT ON VIEW "view_NGWMN_Lithology" IS ' + "'Lithology intervals in the NGWMN exchange format, sourced from " + "the Ocotillo geologic formation associations.'" + ) + ) + + +def downgrade() -> None: + op.execute(text(DROP_LITHOLOGY_SQL)) + op.execute(text(DROP_WELLCONSTRUCTION_SQL)) + op.execute(text(DROP_WATERLEVELS_SQL)) From c009966ad89b4f4f6aaa3e42ca0cf997c706d54b Mon Sep 17 00:00:00 2001 From: jross Date: Thu, 11 Jun 2026 15:56:40 -0600 Subject: [PATCH 032/160] feat: serve NGWMN exports from new Ocotillo-model views Point the /ngwmn endpoint queries at view_NGWMN_WaterLevels, view_NGWMN_WellConstruction, and view_NGWMN_Lithology instead of the legacy NMA_view_NGWMN_* copy tables, and add endpoint tests covering the XML output, BGS depth math, timezone date handling, measurement method and accuracy mapping, and private-record exclusion. Co-Authored-By: Claude Fable 5 --- services/ngwmn_helper.py | 6 +- tests/test_ngwmn_endpoints.py | 252 ++++++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 tests/test_ngwmn_endpoints.py diff --git a/services/ngwmn_helper.py b/services/ngwmn_helper.py index 73df11586..94b52c111 100644 --- a/services/ngwmn_helper.py +++ b/services/ngwmn_helper.py @@ -39,7 +39,7 @@ def make_xml_response(db, sql, point_id, func): def make_lithology_response(point_id, db): sql = ( 'select "PointID", "StratTop", "StratBottom", "TERM" ' - 'from "NMA_view_NGWMN_Lithology" where "PointID"=:point_id' + 'from "view_NGWMN_Lithology" where "PointID"=:point_id' ) return make_xml_response(db, sql, point_id, lithology_xml) @@ -48,14 +48,14 @@ def make_well_construction_response(point_id, db): sql = ( 'select "PointID", "CasingTop", "CasingBottom", "CasingDepthUnits", ' '"ScreenTop", "ScreenBottom", "ScreenBottomUnit", "ScreenDescription", "CasingDescription" ' - 'from "NMA_view_NGWMN_WellConstruction" where "PointID"=:point_id' + 'from "view_NGWMN_WellConstruction" where "PointID"=:point_id' ) return make_xml_response(db, sql, point_id, well_construction_xml) def make_waterlevels_response(point_id, db): sql = ( - 'select * from "NMA_view_NGWMN_WaterLevels" where "PointID"=:point_id ' + 'select * from "view_NGWMN_WaterLevels" where "PointID"=:point_id ' 'order by "DateMeasured"' ) sql2 = ( diff --git a/tests/test_ngwmn_endpoints.py b/tests/test_ngwmn_endpoints.py new file mode 100644 index 000000000..32db27369 --- /dev/null +++ b/tests/test_ngwmn_endpoints.py @@ -0,0 +1,252 @@ +# =============================================================================== +# 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 /ngwmn endpoints backed by the view_NGWMN_* views, which are +sourced from the new Ocotillo data model (thing/well_screen/observation/ +thing_geologic_formation_association) rather than the legacy NMA_view_NGWMN_* +copy tables. +""" + +from xml.etree import ElementTree as etree + +import pytest +from sqlalchemy import delete + +from db import ( + FieldActivity, + FieldEvent, + GeologicFormation, + Observation, + Sample, + Thing, + ThingGeologicFormationAssociation, + WellCasingMaterial, + WellScreen, +) +from db.engine import session_ctx +from tests import client, get_parameter_id + +POINT_ID = "NGWMN-TEST-0001" + + +@pytest.fixture(scope="module") +def ngwmn_well(): + """A public water well with casing, screen, lithology, and water levels.""" + with session_ctx() as session: + thing = Thing( + name=POINT_ID, + thing_type="water well", + release_status="public", + well_depth=150.0, + well_casing_depth=120.5, + ) + session.add(thing) + session.flush() + + session.add( + WellScreen( + thing_id=thing.id, + screen_depth_top=80.0, + screen_depth_bottom=120.0, + screen_description="4in slotted", + release_status="public", + ) + ) + session.add( + WellCasingMaterial( + thing_id=thing.id, material="Steel", release_status="public" + ) + ) + + formation = GeologicFormation( + formation_code=None, + lithology="Sandstone", + release_status="public", + ) + session.add(formation) + session.flush() + session.add( + ThingGeologicFormationAssociation( + thing_id=thing.id, + geologic_formation_id=formation.id, + top_depth=0.0, + bottom_depth=60.0, + release_status="public", + ) + ) + + event = FieldEvent( + thing_id=thing.id, + event_date="2024-03-15T19:00:00Z", + release_status="public", + ) + session.add(event) + session.flush() + activity = FieldActivity( + field_event_id=event.id, + activity_type="groundwater level", + release_status="public", + ) + session.add(activity) + session.flush() + + parameter_id = get_parameter_id("groundwater level", "Field Parameter") + + observations = [ + # Real time component: 19:00 UTC is 13:00 MDT, so the measured + # date is 2024-03-15 local. BGS = 50.0 - 2.5 = 47.50. + { + "sample_name": f"{POINT_ID}-wl-1", + "sample_method": "Steel-tape measurement", + "observation_datetime": "2024-03-15T19:00:00Z", + "value": 50.0, + "measuring_point_height": 2.5, + "nma_data_quality": "Water level accurate to within two hundreths of a foot", + "release_status": "public", + }, + # Midnight UTC means no time was measured during transfer, so the + # UTC date is kept. No MP height: BGS = value. + { + "sample_name": f"{POINT_ID}-wl-2", + "sample_method": "Pressure-gage measurement", + "observation_datetime": "2024-04-01T00:00:00Z", + "value": 33.0, + "measuring_point_height": None, + "nma_data_quality": "Water level accurate to within one foot", + "release_status": "public", + }, + # Private observations must not appear in the NGWMN export. + { + "sample_name": f"{POINT_ID}-wl-3", + "sample_method": "Steel-tape measurement", + "observation_datetime": "2024-05-01T00:00:00Z", + "value": 12.0, + "measuring_point_height": None, + "nma_data_quality": None, + "release_status": "private", + }, + ] + for obs in observations: + sample = Sample( + field_activity_id=activity.id, + sample_date=obs["observation_datetime"], + sample_name=obs["sample_name"], + sample_matrix="water", + sample_method=obs["sample_method"], + qc_type="Normal", + release_status=obs["release_status"], + ) + session.add(sample) + session.flush() + session.add( + Observation( + sample_id=sample.id, + parameter_id=parameter_id, + observation_datetime=obs["observation_datetime"], + value=obs["value"], + unit="ft", + measuring_point_height=obs["measuring_point_height"], + nma_data_quality=obs["nma_data_quality"], + release_status=obs["release_status"], + ) + ) + session.commit() + thing_id = thing.id + formation_id = formation.id + + yield POINT_ID + + with session_ctx() as session: + # Thing delete cascades to screens, casing materials, field events + # (and through to samples/observations), and formation associations. + session.execute(delete(Thing).where(Thing.id == thing_id)) + session.execute( + delete(GeologicFormation).where(GeologicFormation.id == formation_id) + ) + session.commit() + + +def test_ngwmn_waterlevels(ngwmn_well): + response = client.get(f"/ngwmn/waterlevels/{ngwmn_well}") + assert response.status_code == 200 + + root = etree.fromstring(response.content) + assert root.tag == "WaterLevels" + levels = root.findall("WaterLevel") + assert len(levels) == 2 + + first, second = levels + assert first.findtext("PointID") == ngwmn_well + assert first.findtext("DepthFromLandSurfaceData") == "47.50" + assert first.findtext("WaterLevelUnits") == "ft bgs" + assert first.findtext("MeasuringMethod") == "Steel tape" + assert first.findtext("MeasurementYear") == "2024" + assert first.findtext("MeasurementMonth") == "3" + assert first.findtext("MeasurementDay") == "15" + assert first.findtext("WaterLevelAccuracy") == "0.02 ft" + + assert second.findtext("DepthFromLandSurfaceData") == "33.00" + assert second.findtext("MeasuringMethod") == "Pressure gauge" + assert second.findtext("MeasurementMonth") == "4" + assert second.findtext("MeasurementDay") == "1" + assert second.findtext("WaterLevelAccuracy") == "1.0 ft" + + +def test_ngwmn_wellconstruction(ngwmn_well): + response = client.get(f"/ngwmn/wellconstruction/{ngwmn_well}") + assert response.status_code == 200 + + root = etree.fromstring(response.content) + assert root.tag == "Casings" + casings = root.findall("Casing") + assert len(casings) == 1 + + casing = casings[0] + assert casing.findtext("PointID") == ngwmn_well + assert float(casing.findtext("CasingTop")) == 0.0 + assert float(casing.findtext("CasingBottom")) == 120.5 + assert casing.findtext("CasingDepthUnits") == "ft bgs" + assert float(casing.findtext("ScreenTop")) == 80.0 + assert float(casing.findtext("ScreenBottom")) == 120.0 + assert casing.findtext("ScreenDescription") == "4in slotted" + + +def test_ngwmn_lithology(ngwmn_well): + response = client.get(f"/ngwmn/lithology/{ngwmn_well}") + assert response.status_code == 200 + + root = etree.fromstring(response.content) + assert root.tag == "Lithologies" + lithologies = root.findall("Lithology") + assert len(lithologies) == 1 + + lithology = lithologies[0] + assert lithology.findtext("PointID") == ngwmn_well + assert float(lithology.findtext("TopDepth")) == 0.0 + assert float(lithology.findtext("BottomDepth")) == 60.0 + assert lithology.findtext("Units") == "feet" + assert lithology.findtext("Description") == "Sandstone" + + +def test_ngwmn_unknown_pointid_returns_empty(): + response = client.get("/ngwmn/waterlevels/NO-SUCH-POINTID") + assert response.status_code == 200 + root = etree.fromstring(response.content) + assert root.tag == "WaterLevels" + assert len(root.findall("WaterLevel")) == 0 + + +# ============= EOF ============================================= From 7851008011959784a2c35d2bc73c299f528fea5a Mon Sep 17 00:00:00 2001 From: jross Date: Thu, 11 Jun 2026 16:12:09 -0600 Subject: [PATCH 033/160] feat: add transducer_daily_data materialized view Aggregate raw transducer observations into one row per well, parameter, day, and QC status, replacing the legacy NMA_WaterLevelsContinuous_Pressure_Daily table as the daily rollup. Includes a unique index so the view supports REFRESH MATERIALIZED VIEW CONCURRENTLY via the refresh CLI. Co-Authored-By: Claude Fable 5 --- ...transducer_daily_data_materialized_view.py | 116 +++++++++++++ tests/test_transducer_daily_data.py | 162 ++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 alembic/versions/v0w1x2y3z4a5_add_transducer_daily_data_materialized_view.py create mode 100644 tests/test_transducer_daily_data.py diff --git a/alembic/versions/v0w1x2y3z4a5_add_transducer_daily_data_materialized_view.py b/alembic/versions/v0w1x2y3z4a5_add_transducer_daily_data_materialized_view.py new file mode 100644 index 000000000..c8d859847 --- /dev/null +++ b/alembic/versions/v0w1x2y3z4a5_add_transducer_daily_data_materialized_view.py @@ -0,0 +1,116 @@ +"""add transducer daily data materialized view + +Aggregates raw transducer observations into one row per well, parameter, +day, and QC status. This is the new-model replacement for the legacy +NMA_WaterLevelsContinuous_Pressure_Daily table, which AMPAPI rebuilt +nightly from the raw continuous pressure record. + +Revision ID: v0w1x2y3z4a5 +Revises: u8v9w0x1y2z3 +Create Date: 2026-06-11 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "v0w1x2y3z4a5" +down_revision: Union[str, Sequence[str], None] = "u8v9w0x1y2z3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +REQUIRED_TABLES = { + "transducer_observation", + "deployment", + "thing", + "parameter", +} + +DROP_VIEW_SQL = "DROP MATERIALIZED VIEW IF EXISTS transducer_daily_data" + + +def _create_transducer_daily_data_view() -> str: + # transducer_observation.value is depth to water in feet below ground + # surface (the transfer wrote DepthToWaterBGS directly), so the daily + # depth columns need no measuring-point correction. + # + # The transfer wrote legacy timestamps unshifted (naive local clock + # readings stored as UTC), so bucketing on the UTC date preserves the + # original measurement dates. + # + # QC status: the transfer marked QCed rows release_status='public' and + # un-reviewed rows 'private', so qced mirrors the legacy QCed flag. + return """ + CREATE MATERIALIZED VIEW transducer_daily_data AS + SELECT + d.thing_id, + t.name AS point_id, + tob.parameter_id, + p.parameter_name, + (tob.observation_datetime AT TIME ZONE 'UTC')::date AS date_measured, + (tob.release_status = 'public') AS qced, + avg(tob.value) AS depth_to_water_bgs, + min(tob.value) AS depth_to_water_bgs_min, + max(tob.value) AS depth_to_water_bgs_max, + count(*) AS measurement_count, + min(tob.observation_datetime) AS first_measurement_at, + max(tob.observation_datetime) AS last_measurement_at, + avg(tob.nma_waterlevelscontinuous_pressure_temperature_water) AS temperature_water, + avg(tob.nma_waterlevelscontinuous_pressure_water_head) AS water_head, + avg(tob.nma_waterlevelscontinuous_pressure_water_head_adjusted) AS water_head_adjusted, + avg(tob.nma_waterlevelscontinuous_pressure_conddl_ms_cm) AS conddl_ms_cm + FROM transducer_observation AS tob + JOIN deployment AS d ON d.id = tob.deployment_id + JOIN thing AS t ON t.id = d.thing_id + JOIN parameter AS p ON p.id = tob.parameter_id + GROUP BY + d.thing_id, + t.name, + tob.parameter_id, + p.parameter_name, + (tob.observation_datetime AT TIME ZONE 'UTC')::date, + (tob.release_status = 'public') + """ + + +def upgrade() -> 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 transducer_daily_data. Missing required tables: " + f"{', '.join(sorted(missing))}" + ) + + op.execute(text(DROP_VIEW_SQL)) + op.execute(text(_create_transducer_daily_data_view())) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW transducer_daily_data IS " + "'Daily aggregates of transducer observations per well, parameter, " + "and QC status. Replacement for the legacy " + "NMA_WaterLevelsContinuous_Pressure_Daily table. Refresh with " + "REFRESH MATERIALIZED VIEW CONCURRENTLY transducer_daily_data.'" + ) + ) + # Unique index required for REFRESH MATERIALIZED VIEW CONCURRENTLY. + op.execute( + text( + "CREATE UNIQUE INDEX ux_transducer_daily_data_key " + "ON transducer_daily_data (thing_id, parameter_id, date_measured, qced)" + ) + ) + op.execute( + text( + "CREATE INDEX ix_transducer_daily_data_point_id_date " + "ON transducer_daily_data (point_id, date_measured)" + ) + ) + + +def downgrade() -> None: + op.execute(text(DROP_VIEW_SQL)) diff --git a/tests/test_transducer_daily_data.py b/tests/test_transducer_daily_data.py new file mode 100644 index 000000000..9d5d32356 --- /dev/null +++ b/tests/test_transducer_daily_data.py @@ -0,0 +1,162 @@ +# =============================================================================== +# 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 transducer_daily_data materialized view, which aggregates raw +transducer observations into one row per well, parameter, day, and QC status. +""" + +from datetime import date + +import pytest +from sqlalchemy import delete, text + +from db import Deployment, Sensor, Thing, TransducerObservation +from db.engine import session_ctx +from tests import get_parameter_id + +POINT_ID = "TDD-TEST-0001" + + +def _refresh_view(session): + session.execute(text("REFRESH MATERIALIZED VIEW transducer_daily_data")) + session.commit() + + +@pytest.fixture(scope="module") +def transducer_well(): + """A well with a transducer deployment and two days of observations.""" + with session_ctx() as session: + thing = Thing(name=POINT_ID, thing_type="water well", release_status="public") + session.add(thing) + session.flush() + + sensor = Sensor( + name=f"{POINT_ID}-transducer", + sensor_type="Pressure Transducer", + release_status="public", + ) + session.add(sensor) + session.flush() + + deployment = Deployment( + thing_id=thing.id, + sensor_id=sensor.id, + installation_date="2024-01-01", + release_status="public", + ) + session.add(deployment) + session.flush() + + parameter_id = get_parameter_id("groundwater level", "Field Parameter") + + observations = [ + # Day 1: three QCed readings -> avg 12.0, min 10.0, max 14.0. + ("2024-03-15T06:00:00Z", 10.0, "public", 8.5), + ("2024-03-15T12:00:00Z", 12.0, "public", 9.0), + ("2024-03-15T18:00:00Z", 14.0, "public", 9.5), + # Day 1: one un-QCed reading -> separate row. + ("2024-03-15T13:00:00Z", 99.0, "private", None), + # Day 2: two QCed readings -> avg 21.0. + ("2024-03-16T06:00:00Z", 20.0, "public", None), + ("2024-03-16T18:00:00Z", 22.0, "public", None), + ] + for dt, value, release_status, temperature in observations: + session.add( + TransducerObservation( + deployment_id=deployment.id, + parameter_id=parameter_id, + observation_datetime=dt, + value=value, + release_status=release_status, + nma_waterlevelscontinuous_pressure_temperature_water=temperature, + ) + ) + session.commit() + thing_id = thing.id + sensor_id = sensor.id + + _refresh_view(session) + + yield thing_id + + with session_ctx() as session: + # Thing delete cascades to the deployment and its observations. + session.execute(delete(Thing).where(Thing.id == thing_id)) + session.execute(delete(Sensor).where(Sensor.id == sensor_id)) + session.commit() + _refresh_view(session) + + +def _fetch_rows(session, thing_id): + return ( + session.execute( + text( + "SELECT * FROM transducer_daily_data " + "WHERE thing_id = :thing_id ORDER BY date_measured, qced" + ), + {"thing_id": thing_id}, + ) + .mappings() + .all() + ) + + +def test_transducer_daily_data_aggregation(transducer_well): + with session_ctx() as session: + rows = _fetch_rows(session, transducer_well) + + assert len(rows) == 3 + + day1_private, day1_public, day2_public = rows + + assert day1_private["point_id"] == POINT_ID + assert day1_private["parameter_name"] == "groundwater level" + assert day1_private["date_measured"] == date(2024, 3, 15) + assert day1_private["qced"] is False + assert day1_private["measurement_count"] == 1 + assert day1_private["depth_to_water_bgs"] == pytest.approx(99.0) + + assert day1_public["date_measured"] == date(2024, 3, 15) + assert day1_public["qced"] is True + assert day1_public["measurement_count"] == 3 + assert day1_public["depth_to_water_bgs"] == pytest.approx(12.0) + assert day1_public["depth_to_water_bgs_min"] == pytest.approx(10.0) + assert day1_public["depth_to_water_bgs_max"] == pytest.approx(14.0) + assert day1_public["temperature_water"] == pytest.approx(9.0) + + assert day2_public["date_measured"] == date(2024, 3, 16) + assert day2_public["qced"] is True + assert day2_public["measurement_count"] == 2 + assert day2_public["depth_to_water_bgs"] == pytest.approx(21.0) + assert day2_public["temperature_water"] is None + + +def test_transducer_daily_data_concurrent_refresh(transducer_well): + # The unique index on (thing_id, parameter_id, date_measured, qced) must + # support CONCURRENTLY, which the refresh CLI uses in production. + from db.engine import engine + + with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: + conn.execute( + text("REFRESH MATERIALIZED VIEW CONCURRENTLY transducer_daily_data") + ) + + with session_ctx() as session: + rows = _fetch_rows(session, transducer_well) + assert len(rows) == 3 + + +# ============= EOF ============================================= From 7036a629f1818ac12a8e68a74e97b36acfc22c3c Mon Sep 17 00:00:00 2001 From: jross Date: Thu, 11 Jun 2026 16:23:20 -0600 Subject: [PATCH 034/160] feat: source NGWMN continuous water levels from transducer_daily_data Replace the legacy NMA_WaterLevelsContinuous_Pressure_Daily query in make_waterlevels_response with the transducer_daily_data materialized view, and simplify the merge/XML code to use the view's (point_id, date_measured, depth_to_water_bgs) rows instead of the 19-column positional legacy layout. The merge rule is unchanged: on overlapping dates the manual reading wins when shallower, and only one record is emitted per date. Guard the depth comparison against NULLs. Co-Authored-By: Claude Fable 5 --- services/ngwmn_helper.py | 130 +++++++++++------------------ tests/test_ngwmn_endpoints.py | 152 +++++++++++++++++++++++++++++++++- 2 files changed, 198 insertions(+), 84 deletions(-) diff --git a/services/ngwmn_helper.py b/services/ngwmn_helper.py index 94b52c111..c6aa55dd5 100644 --- a/services/ngwmn_helper.py +++ b/services/ngwmn_helper.py @@ -59,8 +59,11 @@ def make_waterlevels_response(point_id, db): 'order by "DateMeasured"' ) sql2 = ( - 'select * from "NMA_WaterLevelsContinuous_Pressure_Daily" where "PointID"=:point_id and "QCed" is true ' - 'order by "DateMeasured"' + "select point_id, date_measured, depth_to_water_bgs " + "from transducer_daily_data " + "where point_id=:point_id and qced is true " + "and parameter_name='groundwater level' " + "order by date_measured" ) return make_xml_response(db, (sql, sql2), point_id, water_levels_xml2) @@ -76,59 +79,43 @@ def water_levels_xml(records): def water_levels_xml2(manual, pressure): + """ + Merge manual measurements (view_NGWMN_WaterLevels rows) with daily + transducer aggregates (transducer_daily_data rows). Both row types carry + (PointID, date, depth to water bgs, ...) in their first three columns. + + When both sources have a measurement on the same date, the manual reading + wins if it is shallower; either way only one record is emitted per date. + """ if not pressure: return make_xml("WaterLevels", manual, make_water_level) - else: - root = etree.Element("WaterLevels") - # doc = etree.ElementTree(root) - - columns = [ - "GlobalID", - "OBJECTID", - "WellID", - "PointID", - "DateMeasured", - "TemperatureWater", - "WaterHead", - "WaterHeadAdjusted", - "DepthToWaterBGS", - "MeasurementMethod", - "DataSource", - "MeasuringAgency", - "QCed", - "Notes", - "Created", - "Updated", - "ProcessedBy", - "CheckedBy", - "CONDDL (mS/cm)", - ] - - manual_dates = [r[1] for r in manual] - records = [] - for r in pressure: - dm = r[columns.index("DateMeasured")] - tag = "pressure" - if dm.date() in manual_dates: - ri = next((ri for ri in manual if ri[1] == dm.date())) - if ri[2] < r[columns.index("DepthToWaterBGS")]: - r = ri - tag = "manual" - manual.remove(ri) - - records.append((tag, r)) - - for mi in manual: - records.append(("manual", mi)) - - for k, record in sorted( - records, key=lambda r: r[1][4].date() if r[0] == "pressure" else r[1][1] - ): - if k == "pressure": - make_continuous_water_level(root, record) - else: - make_water_level(root, record) - return etree.tostring(root) + + root = etree.Element("WaterLevels") + + manual = list(manual) + manual_dates = [r[1] for r in manual] + records = [] + for r in pressure: + dm = r[1] + tag = "pressure" + if dm in manual_dates: + ri = next(ri for ri in manual if ri[1] == dm) + if ri[2] is not None and r[2] is not None and ri[2] < r[2]: + r = ri + tag = "manual" + manual.remove(ri) + + records.append((tag, r)) + + for mi in manual: + records.append(("manual", mi)) + + for k, record in sorted(records, key=lambda r: r[1][1]): + if k == "pressure": + make_continuous_water_level(root, record) + else: + make_water_level(root, record) + return etree.tostring(root) def well_construction_xml(records): @@ -153,39 +140,16 @@ def make_xml(name, records, make_record): # ==================== make records ======================= def make_continuous_water_level(root, r): + """ + r is a transducer_daily_data row: (point_id, date_measured, depth_to_water_bgs) + """ elem = etree.SubElement(root, "WaterLevel") - make_point_id(elem, r, idx=3) - - columns = [ - "GlobalID", - "OBJECTID", - "WellID", - "PointID", - "DateMeasured", - "TemperatureWater", - "WaterHead", - "WaterHeadAdjusted", - "DepthToWaterBGS", - "MeasurementMethod", - "DataSource", - "MeasuringAgency", - "QCed", - "Notes", - "Created", - "Updated", - "ProcessedBy", - "CheckedBy", - "CONDDL (mS/cm)", - ] - - m = r[columns.index("DateMeasured")] + make_point_id(elem, r) + + m = r[1] - # m = datetime.strptime(m, '%Y-%m-%d') for attr, val in ( - ( - "DepthFromLandSurfaceData", - "{:0.2f}".format(r[columns.index("DepthToWaterBGS")]), - ), + ("DepthFromLandSurfaceData", "{:0.2f}".format(r[2])), ("WaterLevelUnits", "ft bgs"), ("MeasuringMethod", "Pressure Transducer"), ("MeasurementMonth", m.month), diff --git a/tests/test_ngwmn_endpoints.py b/tests/test_ngwmn_endpoints.py index 32db27369..7998d31e3 100644 --- a/tests/test_ngwmn_endpoints.py +++ b/tests/test_ngwmn_endpoints.py @@ -23,16 +23,19 @@ from xml.etree import ElementTree as etree import pytest -from sqlalchemy import delete +from sqlalchemy import delete, text from db import ( + Deployment, FieldActivity, FieldEvent, GeologicFormation, Observation, Sample, + Sensor, Thing, ThingGeologicFormationAssociation, + TransducerObservation, WellCasingMaterial, WellScreen, ) @@ -40,6 +43,7 @@ from tests import client, get_parameter_id POINT_ID = "NGWMN-TEST-0001" +MERGED_POINT_ID = "NGWMN-TEST-0002" @pytest.fixture(scope="module") @@ -179,6 +183,118 @@ def ngwmn_well(): session.commit() +@pytest.fixture(scope="module") +def ngwmn_merged_well(): + """A well with both manual water levels and daily transducer aggregates.""" + with session_ctx() as session: + thing = Thing( + name=MERGED_POINT_ID, thing_type="water well", release_status="public" + ) + session.add(thing) + session.flush() + + parameter_id = get_parameter_id("groundwater level", "Field Parameter") + + event = FieldEvent( + thing_id=thing.id, + event_date="2024-03-15T19:00:00Z", + release_status="public", + ) + session.add(event) + session.flush() + activity = FieldActivity( + field_event_id=event.id, + activity_type="groundwater level", + release_status="public", + ) + session.add(activity) + session.flush() + + manual_levels = [ + # 2024-03-15 local date, BGS = 50.0 - 2.5 = 47.50 + ("2024-03-15T19:00:00Z", 50.0, 2.5), + # 2024-04-01 (midnight UTC kept as-is), BGS = 33.00 + ("2024-04-01T00:00:00Z", 33.0, None), + ] + for i, (dt, value, mph) in enumerate(manual_levels): + sample = Sample( + field_activity_id=activity.id, + sample_date=dt, + sample_name=f"{MERGED_POINT_ID}-wl-{i}", + sample_matrix="water", + sample_method="Steel-tape measurement", + qc_type="Normal", + release_status="public", + ) + session.add(sample) + session.flush() + session.add( + Observation( + sample_id=sample.id, + parameter_id=parameter_id, + observation_datetime=dt, + value=value, + unit="ft", + measuring_point_height=mph, + nma_data_quality=None, + release_status="public", + ) + ) + + sensor = Sensor( + name=f"{MERGED_POINT_ID}-transducer", + sensor_type="Pressure Transducer", + release_status="public", + ) + session.add(sensor) + session.flush() + deployment = Deployment( + thing_id=thing.id, + sensor_id=sensor.id, + installation_date="2024-01-01", + release_status="public", + ) + session.add(deployment) + session.flush() + + transducer_readings = [ + # 2024-03-15 daily avg 50.0: manual 47.50 is shallower and wins. + ("2024-03-15T06:00:00Z", 49.0), + ("2024-03-15T18:00:00Z", 51.0), + # 2024-03-20 daily avg 30.0: transducer-only date. + ("2024-03-20T06:00:00Z", 29.0), + ("2024-03-20T18:00:00Z", 31.0), + # 2024-04-01 daily avg 20.0: manual 33.00 is deeper and loses. + ("2024-04-01T06:00:00Z", 19.0), + ("2024-04-01T18:00:00Z", 21.0), + ] + for dt, value in transducer_readings: + session.add( + TransducerObservation( + deployment_id=deployment.id, + parameter_id=parameter_id, + observation_datetime=dt, + value=value, + release_status="public", + ) + ) + session.commit() + thing_id = thing.id + sensor_id = sensor.id + + session.execute(text("REFRESH MATERIALIZED VIEW transducer_daily_data")) + session.commit() + + yield MERGED_POINT_ID + + with session_ctx() as session: + session.execute(delete(Thing).where(Thing.id == thing_id)) + session.execute(delete(Sensor).where(Sensor.id == sensor_id)) + session.commit() + session.execute(text("REFRESH MATERIALIZED VIEW transducer_daily_data")) + session.commit() + + def test_ngwmn_waterlevels(ngwmn_well): response = client.get(f"/ngwmn/waterlevels/{ngwmn_well}") assert response.status_code == 200 @@ -241,6 +357,40 @@ def test_ngwmn_lithology(ngwmn_well): assert lithology.findtext("Description") == "Sandstone" +def test_ngwmn_waterlevels_merges_manual_and_transducer(ngwmn_merged_well): + response = client.get(f"/ngwmn/waterlevels/{ngwmn_merged_well}") + assert response.status_code == 200 + + root = etree.fromstring(response.content) + assert root.tag == "WaterLevels" + levels = root.findall("WaterLevel") + assert len(levels) == 3 + + first, second, third = levels + + # Same-date overlap where the manual reading is shallower: manual wins. + assert first.findtext("PointID") == ngwmn_merged_well + assert first.findtext("DepthFromLandSurfaceData") == "47.50" + assert first.findtext("MeasuringMethod") == "Steel tape" + assert first.findtext("MeasurementMonth") == "3" + assert first.findtext("MeasurementDay") == "15" + + # Transducer-only date: daily average is emitted. + assert second.findtext("DepthFromLandSurfaceData") == "30.00" + assert second.findtext("MeasuringMethod") == "Pressure Transducer" + assert second.findtext("WaterLevelUnits") == "ft bgs" + assert second.findtext("WaterLevelAccuracy") == "0.02 ft" + assert second.findtext("MeasurementMonth") == "3" + assert second.findtext("MeasurementDay") == "20" + + # Same-date overlap where the manual reading is deeper: transducer wins + # and the manual record is dropped. + assert third.findtext("DepthFromLandSurfaceData") == "20.00" + assert third.findtext("MeasuringMethod") == "Pressure Transducer" + assert third.findtext("MeasurementMonth") == "4" + assert third.findtext("MeasurementDay") == "1" + + def test_ngwmn_unknown_pointid_returns_empty(): response = client.get("/ngwmn/waterlevels/NO-SUCH-POINTID") assert response.status_code == 200 From b00d0a43038e46edff10e1d9aef2a2d330d63653 Mon Sep 17 00:00:00 2001 From: jross Date: Thu, 11 Jun 2026 16:29:52 -0600 Subject: [PATCH 035/160] refactor: rename NGWMN views to drop view_ prefix view_NGWMN_WaterLevels, view_NGWMN_WellConstruction, and view_NGWMN_Lithology become NGWMN_WaterLevels, NGWMN_WellConstruction, and NGWMN_Lithology. The migration has not shipped, so it is edited in place rather than adding a rename migration. References to the legacy AMPAPI view names and CSV source tables keep the view_ prefix. Co-Authored-By: Claude Fable 5 --- ...2z3_add_ngwmn_views_from_ocotillo_model.py | 24 +++++++++---------- services/ngwmn_helper.py | 8 +++---- tests/test_ngwmn_endpoints.py | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/alembic/versions/u8v9w0x1y2z3_add_ngwmn_views_from_ocotillo_model.py b/alembic/versions/u8v9w0x1y2z3_add_ngwmn_views_from_ocotillo_model.py index d878e36f9..bad96a0e3 100644 --- a/alembic/versions/u8v9w0x1y2z3_add_ngwmn_views_from_ocotillo_model.py +++ b/alembic/versions/u8v9w0x1y2z3_add_ngwmn_views_from_ocotillo_model.py @@ -4,9 +4,9 @@ exports. These views reproduce the original AMPAPI (SQL Server) view definitions but read from the new Ocotillo tables: -- view_NGWMN_WaterLevels: observation/sample/field_activity/field_event/thing -- view_NGWMN_WellConstruction: thing/well_screen/well_casing_material -- view_NGWMN_Lithology: thing_geologic_formation_association/geologic_formation +- NGWMN_WaterLevels: observation/sample/field_activity/field_event/thing +- NGWMN_WellConstruction: thing/well_screen/well_casing_material +- NGWMN_Lithology: thing_geologic_formation_association/geologic_formation Revision ID: u8v9w0x1y2z3 Revises: t6u7v8w9x0y1 @@ -37,9 +37,9 @@ "geologic_formation", } -DROP_WATERLEVELS_SQL = 'DROP VIEW IF EXISTS "view_NGWMN_WaterLevels"' -DROP_WELLCONSTRUCTION_SQL = 'DROP VIEW IF EXISTS "view_NGWMN_WellConstruction"' -DROP_LITHOLOGY_SQL = 'DROP VIEW IF EXISTS "view_NGWMN_Lithology"' +DROP_WATERLEVELS_SQL = 'DROP VIEW IF EXISTS "NGWMN_WaterLevels"' +DROP_WELLCONSTRUCTION_SQL = 'DROP VIEW IF EXISTS "NGWMN_WellConstruction"' +DROP_LITHOLOGY_SQL = 'DROP VIEW IF EXISTS "NGWMN_Lithology"' def _create_waterlevels_view() -> str: @@ -63,7 +63,7 @@ def _create_waterlevels_view() -> str: # and LU_DataQuality), including the legacy quirk mapping code O # ("Observed...") to 'Acoustic Sounder'. return """ - CREATE VIEW "view_NGWMN_WaterLevels" AS + CREATE VIEW "NGWMN_WaterLevels" AS SELECT t.name AS "PointID", CASE @@ -109,7 +109,7 @@ def _create_wellconstruction_view() -> str: # to controlled material terms during transfer, so it is rebuilt here as # a comma-separated list of well_casing_material terms. return """ - CREATE VIEW "view_NGWMN_WellConstruction" AS + CREATE VIEW "NGWMN_WellConstruction" AS SELECT t.name AS "PointID", CASE WHEN t.well_casing_depth IS NOT NULL THEN 0::double precision END AS "CasingTop", @@ -139,7 +139,7 @@ def _create_lithology_view() -> str: # Lithology and TERM columns. The inner join is reproduced by requiring # a non-null lithology. StratSource was not migrated and is NULL. return """ - CREATE VIEW "view_NGWMN_Lithology" AS + CREATE VIEW "NGWMN_Lithology" AS SELECT tgfa.id AS "OBJECTID", t.name AS "PointID", @@ -172,7 +172,7 @@ def upgrade() -> None: op.execute(text(_create_waterlevels_view())) op.execute( text( - 'COMMENT ON VIEW "view_NGWMN_WaterLevels" IS ' + 'COMMENT ON VIEW "NGWMN_WaterLevels" IS ' "'Public manual groundwater level measurements in the NGWMN " "exchange format, sourced from the Ocotillo observation model.'" ) @@ -182,7 +182,7 @@ def upgrade() -> None: op.execute(text(_create_wellconstruction_view())) op.execute( text( - 'COMMENT ON VIEW "view_NGWMN_WellConstruction" IS ' + 'COMMENT ON VIEW "NGWMN_WellConstruction" IS ' "'Well casing and screen intervals in the NGWMN exchange format, " "sourced from the Ocotillo thing/well_screen model.'" ) @@ -192,7 +192,7 @@ def upgrade() -> None: op.execute(text(_create_lithology_view())) op.execute( text( - 'COMMENT ON VIEW "view_NGWMN_Lithology" IS ' + 'COMMENT ON VIEW "NGWMN_Lithology" IS ' "'Lithology intervals in the NGWMN exchange format, sourced from " "the Ocotillo geologic formation associations.'" ) diff --git a/services/ngwmn_helper.py b/services/ngwmn_helper.py index c6aa55dd5..77431b9c9 100644 --- a/services/ngwmn_helper.py +++ b/services/ngwmn_helper.py @@ -39,7 +39,7 @@ def make_xml_response(db, sql, point_id, func): def make_lithology_response(point_id, db): sql = ( 'select "PointID", "StratTop", "StratBottom", "TERM" ' - 'from "view_NGWMN_Lithology" where "PointID"=:point_id' + 'from "NGWMN_Lithology" where "PointID"=:point_id' ) return make_xml_response(db, sql, point_id, lithology_xml) @@ -48,14 +48,14 @@ def make_well_construction_response(point_id, db): sql = ( 'select "PointID", "CasingTop", "CasingBottom", "CasingDepthUnits", ' '"ScreenTop", "ScreenBottom", "ScreenBottomUnit", "ScreenDescription", "CasingDescription" ' - 'from "view_NGWMN_WellConstruction" where "PointID"=:point_id' + 'from "NGWMN_WellConstruction" where "PointID"=:point_id' ) return make_xml_response(db, sql, point_id, well_construction_xml) def make_waterlevels_response(point_id, db): sql = ( - 'select * from "view_NGWMN_WaterLevels" where "PointID"=:point_id ' + 'select * from "NGWMN_WaterLevels" where "PointID"=:point_id ' 'order by "DateMeasured"' ) sql2 = ( @@ -80,7 +80,7 @@ def water_levels_xml(records): def water_levels_xml2(manual, pressure): """ - Merge manual measurements (view_NGWMN_WaterLevels rows) with daily + Merge manual measurements (NGWMN_WaterLevels rows) with daily transducer aggregates (transducer_daily_data rows). Both row types carry (PointID, date, depth to water bgs, ...) in their first three columns. diff --git a/tests/test_ngwmn_endpoints.py b/tests/test_ngwmn_endpoints.py index 7998d31e3..631ca7dd3 100644 --- a/tests/test_ngwmn_endpoints.py +++ b/tests/test_ngwmn_endpoints.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== """ -Tests for the /ngwmn endpoints backed by the view_NGWMN_* views, which are +Tests for the /ngwmn endpoints backed by the NGWMN_* views, which are sourced from the new Ocotillo data model (thing/well_screen/observation/ thing_geologic_formation_association) rather than the legacy NMA_view_NGWMN_* copy tables. From ccd1f7bbddbdf0546096a215c87a93d5f7627be9 Mon Sep 17 00:00:00 2001 From: jross Date: Thu, 11 Jun 2026 16:30:39 -0600 Subject: [PATCH 036/160] chore: ignore Claude Code local settings and worktrees Co-Authored-By: Claude Fable 5 --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 92ab7e91d..3c93f2834 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,7 @@ app.yaml docs/ #Codex -.codex \ No newline at end of file +.codex +# Claude Code +.claude/settings.local.json +.claude/worktrees/ From b5a12ea2fb9806bdb2a28b5c86e095feae9462c4 Mon Sep 17 00:00:00 2001 From: jross Date: Fri, 12 Jun 2026 09:24:41 -0600 Subject: [PATCH 037/160] refactor: use ORM view models in ngwmn_helper instead of raw SQL Add read-only ORM mappings for the NGWMN_* views and the transducer_daily_data materialized view on a separate declarative base, kept out of Base.metadata so Alembic autogenerate does not try to create tables for them. The NGWMN response builders now query these models column-wise instead of executing raw SQL strings. Co-Authored-By: Claude Fable 5 --- db/ngwmn_views.py | 120 +++++++++++++++++++++++++++++++++++++++ services/ngwmn_helper.py | 90 ++++++++++++++++++----------- 2 files changed, 178 insertions(+), 32 deletions(-) create mode 100644 db/ngwmn_views.py diff --git a/db/ngwmn_views.py b/db/ngwmn_views.py new file mode 100644 index 000000000..4b8436959 --- /dev/null +++ b/db/ngwmn_views.py @@ -0,0 +1,120 @@ +# =============================================================================== +# 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 ORM mappings over database views. + +These models use their own declarative base, deliberately kept out of +``db.Base.metadata``: the views are created by hand-written Alembic +migrations, and registering them with the main metadata would make +autogenerate try to emit CREATE TABLE statements for them. + +The primary keys declared here exist only to satisfy the ORM mapper. +Query individual columns (``session.query(Model.col, ...)``) rather than +full entities, so the identity map cannot silently collapse view rows +that happen to share a key. +""" + +from datetime import date, datetime + +from sqlalchemy import BigInteger, Boolean, Date, DateTime, Float, Integer, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class ViewBase(DeclarativeBase): + """Declarative base for view mappings, excluded from Alembic metadata.""" + + +class NGWMNWaterLevels(ViewBase): + """The NGWMN_WaterLevels view (manual groundwater level measurements).""" + + __tablename__ = "NGWMN_WaterLevels" + + point_id: Mapped[str] = mapped_column("PointID", String, primary_key=True) + date_measured: Mapped[date] = mapped_column("DateMeasured", Date, primary_key=True) + depth_to_water_bgs: Mapped[float | None] = mapped_column("DepthToWaterBGS", Float) + wl_units: Mapped[str | None] = mapped_column("WLUnits", String) + measurement_method: Mapped[str | None] = mapped_column("MeasurementMethod", String) + wl_accuracy: Mapped[str | None] = mapped_column("WLAccuracy", String) + public_release: Mapped[bool | None] = mapped_column("PublicRelease", Boolean) + + +class NGWMNWellConstruction(ViewBase): + """The NGWMN_WellConstruction view (casing and screen intervals).""" + + __tablename__ = "NGWMN_WellConstruction" + + point_id: Mapped[str] = mapped_column("PointID", String, primary_key=True) + casing_top: Mapped[float | None] = mapped_column( + "CasingTop", Float, primary_key=True + ) + casing_bottom: Mapped[float | None] = mapped_column("CasingBottom", Float) + casing_depth_units: Mapped[str | None] = mapped_column("CasingDepthUnits", String) + screen_top: Mapped[float | None] = mapped_column( + "ScreenTop", Float, primary_key=True + ) + screen_bottom: Mapped[float | None] = mapped_column("ScreenBottom", Float) + screen_bottom_unit: Mapped[str | None] = mapped_column("ScreenBottomUnit", String) + screen_description: Mapped[str | None] = mapped_column("ScreenDescription", String) + casing_description: Mapped[str | None] = mapped_column("CasingDescription", String) + + +class NGWMNLithology(ViewBase): + """The NGWMN_Lithology view (lithology intervals).""" + + __tablename__ = "NGWMN_Lithology" + + object_id: Mapped[int] = mapped_column("OBJECTID", Integer, primary_key=True) + point_id: Mapped[str | None] = mapped_column("PointID", String) + lithology: Mapped[str | None] = mapped_column("Lithology", String) + term: Mapped[str | None] = mapped_column("TERM", String) + strat_source: Mapped[str | None] = mapped_column("StratSource", String) + strat_top: Mapped[float | None] = mapped_column("StratTop", Float) + strat_top_unit: Mapped[str | None] = mapped_column("StratTopUnit", String) + strat_bottom: Mapped[float | None] = mapped_column("StratBottom", Float) + strat_bottom_unit: Mapped[str | None] = mapped_column("StratBottomUnit", String) + + +class TransducerDailyData(ViewBase): + """ + The transducer_daily_data materialized view (daily aggregates of + transducer observations per well, parameter, and QC status). + """ + + __tablename__ = "transducer_daily_data" + + thing_id: Mapped[int] = mapped_column(Integer, primary_key=True) + parameter_id: Mapped[int] = mapped_column(Integer, primary_key=True) + date_measured: Mapped[date] = mapped_column(Date, primary_key=True) + qced: Mapped[bool] = mapped_column(Boolean, primary_key=True) + point_id: Mapped[str | None] = mapped_column(String) + parameter_name: Mapped[str | None] = mapped_column(String) + depth_to_water_bgs: Mapped[float | None] = mapped_column(Float) + depth_to_water_bgs_min: Mapped[float | None] = mapped_column(Float) + depth_to_water_bgs_max: Mapped[float | None] = mapped_column(Float) + measurement_count: Mapped[int | None] = mapped_column(BigInteger) + first_measurement_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True) + ) + last_measurement_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True) + ) + temperature_water: Mapped[float | None] = mapped_column(Float) + water_head: Mapped[float | None] = mapped_column(Float) + water_head_adjusted: Mapped[float | None] = mapped_column(Float) + conddl_ms_cm: Mapped[float | None] = mapped_column(Float) + + +# ============= EOF ============================================= diff --git a/services/ngwmn_helper.py b/services/ngwmn_helper.py index 77431b9c9..c48ea120f 100644 --- a/services/ngwmn_helper.py +++ b/services/ngwmn_helper.py @@ -15,7 +15,12 @@ # =============================================================================== from xml.etree import ElementTree as etree -from sqlalchemy import text +from db.ngwmn_views import ( + NGWMNLithology, + NGWMNWaterLevels, + NGWMNWellConstruction, + TransducerDailyData, +) def _as_text(v): @@ -25,48 +30,69 @@ def _as_text(v): # NSMAP = dict(xsi="http://www.w3.org/2001/XMLSchema-instance", xsd="http://www.w3.org/2001/XMLSchema") -def make_xml_response(db, sql, point_id, func): - if not isinstance(sql, (tuple, list)): - sql = (sql,) - - rs = [] - for si in sql: - records = db.execute(text(si), {"point_id": point_id}) - rs.append(records.fetchall()) - return func(*rs) - - def make_lithology_response(point_id, db): - sql = ( - 'select "PointID", "StratTop", "StratBottom", "TERM" ' - 'from "NGWMN_Lithology" where "PointID"=:point_id' + records = ( + db.query( + NGWMNLithology.point_id, + NGWMNLithology.strat_top, + NGWMNLithology.strat_bottom, + NGWMNLithology.term, + ) + .filter(NGWMNLithology.point_id == point_id) + .all() ) - return make_xml_response(db, sql, point_id, lithology_xml) + return lithology_xml(records) def make_well_construction_response(point_id, db): - sql = ( - 'select "PointID", "CasingTop", "CasingBottom", "CasingDepthUnits", ' - '"ScreenTop", "ScreenBottom", "ScreenBottomUnit", "ScreenDescription", "CasingDescription" ' - 'from "NGWMN_WellConstruction" where "PointID"=:point_id' + records = ( + db.query( + NGWMNWellConstruction.point_id, + NGWMNWellConstruction.casing_top, + NGWMNWellConstruction.casing_bottom, + NGWMNWellConstruction.casing_depth_units, + NGWMNWellConstruction.screen_top, + NGWMNWellConstruction.screen_bottom, + NGWMNWellConstruction.screen_bottom_unit, + NGWMNWellConstruction.screen_description, + NGWMNWellConstruction.casing_description, + ) + .filter(NGWMNWellConstruction.point_id == point_id) + .all() ) - return make_xml_response(db, sql, point_id, well_construction_xml) + return well_construction_xml(records) def make_waterlevels_response(point_id, db): - sql = ( - 'select * from "NGWMN_WaterLevels" where "PointID"=:point_id ' - 'order by "DateMeasured"' + manual = ( + db.query( + NGWMNWaterLevels.point_id, + NGWMNWaterLevels.date_measured, + NGWMNWaterLevels.depth_to_water_bgs, + NGWMNWaterLevels.wl_units, + NGWMNWaterLevels.measurement_method, + NGWMNWaterLevels.wl_accuracy, + NGWMNWaterLevels.public_release, + ) + .filter(NGWMNWaterLevels.point_id == point_id) + .order_by(NGWMNWaterLevels.date_measured) + .all() ) - sql2 = ( - "select point_id, date_measured, depth_to_water_bgs " - "from transducer_daily_data " - "where point_id=:point_id and qced is true " - "and parameter_name='groundwater level' " - "order by date_measured" + pressure = ( + db.query( + TransducerDailyData.point_id, + TransducerDailyData.date_measured, + TransducerDailyData.depth_to_water_bgs, + ) + .filter( + TransducerDailyData.point_id == point_id, + TransducerDailyData.qced.is_(True), + TransducerDailyData.parameter_name == "groundwater level", + ) + .order_by(TransducerDailyData.date_measured) + .all() ) - - return make_xml_response(db, (sql, sql2), point_id, water_levels_xml2) + return water_levels_xml2(manual, pressure) # ==================== make xml ======================= From d7e7c5d18a2b42e25c5dbf03b13b4ee79144971b Mon Sep 17 00:00:00 2001 From: jross Date: Fri, 12 Jun 2026 10:36:14 -0600 Subject: [PATCH 038/160] fix: restrict NGWMN exports to public records across joined entities The NGWMN views filtered only on observation.release_status, so a private thing (or private screen/casing/lithology rows) could still be exported. Require release_status='public' on the thing, field event, field activity, sample, and observation in NGWMN_WaterLevels; on the thing, well_screen, and well_casing_material rows in NGWMN_WellConstruction; and on the thing and formation association in NGWMN_Lithology. The transducer daily query in make_waterlevels_response now joins thing and requires it to be public as well. Co-Authored-By: Claude Fable 5 --- ...2z3_add_ngwmn_views_from_ocotillo_model.py | 12 +- services/ngwmn_helper.py | 3 + tests/test_ngwmn_endpoints.py | 130 ++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/alembic/versions/u8v9w0x1y2z3_add_ngwmn_views_from_ocotillo_model.py b/alembic/versions/u8v9w0x1y2z3_add_ngwmn_views_from_ocotillo_model.py index bad96a0e3..bf76b540e 100644 --- a/alembic/versions/u8v9w0x1y2z3_add_ngwmn_views_from_ocotillo_model.py +++ b/alembic/versions/u8v9w0x1y2z3_add_ngwmn_views_from_ocotillo_model.py @@ -98,6 +98,10 @@ def _create_waterlevels_view() -> str: JOIN parameter AS p ON p.id = o.parameter_id WHERE p.parameter_name = 'groundwater level' AND o.release_status = 'public' + AND s.release_status = 'public' + AND fa.release_status = 'public' + AND fe.release_status = 'public' + AND t.release_status = 'public' """ @@ -121,13 +125,15 @@ def _create_wellconstruction_view() -> str: ws.screen_description AS "ScreenDescription", cm.materials AS "CasingDescription" FROM thing AS t - LEFT JOIN well_screen AS ws ON ws.thing_id = t.id + LEFT JOIN well_screen AS ws + ON ws.thing_id = t.id AND ws.release_status = 'public' LEFT JOIN LATERAL ( SELECT string_agg(wcm.material, ', ' ORDER BY wcm.material) AS materials FROM well_casing_material AS wcm - WHERE wcm.thing_id = t.id + WHERE wcm.thing_id = t.id AND wcm.release_status = 'public' ) AS cm ON TRUE WHERE t.thing_type = 'water well' + AND t.release_status = 'public' """ @@ -154,6 +160,8 @@ def _create_lithology_view() -> str: JOIN thing AS t ON t.id = tgfa.thing_id JOIN geologic_formation AS gf ON gf.id = tgfa.geologic_formation_id WHERE gf.lithology IS NOT NULL + AND tgfa.release_status = 'public' + AND t.release_status = 'public' """ diff --git a/services/ngwmn_helper.py b/services/ngwmn_helper.py index c48ea120f..d164507f1 100644 --- a/services/ngwmn_helper.py +++ b/services/ngwmn_helper.py @@ -15,6 +15,7 @@ # =============================================================================== from xml.etree import ElementTree as etree +from db import Thing from db.ngwmn_views import ( NGWMNLithology, NGWMNWaterLevels, @@ -84,10 +85,12 @@ def make_waterlevels_response(point_id, db): TransducerDailyData.date_measured, TransducerDailyData.depth_to_water_bgs, ) + .join(Thing, Thing.id == TransducerDailyData.thing_id) .filter( TransducerDailyData.point_id == point_id, TransducerDailyData.qced.is_(True), TransducerDailyData.parameter_name == "groundwater level", + Thing.release_status == "public", ) .order_by(TransducerDailyData.date_measured) .all() diff --git a/tests/test_ngwmn_endpoints.py b/tests/test_ngwmn_endpoints.py index 631ca7dd3..cf1a6b86b 100644 --- a/tests/test_ngwmn_endpoints.py +++ b/tests/test_ngwmn_endpoints.py @@ -44,6 +44,7 @@ POINT_ID = "NGWMN-TEST-0001" MERGED_POINT_ID = "NGWMN-TEST-0002" +PRIVATE_POINT_ID = "NGWMN-TEST-0003" @pytest.fixture(scope="module") @@ -391,6 +392,135 @@ def test_ngwmn_waterlevels_merges_manual_and_transducer(ngwmn_merged_well): assert third.findtext("MeasurementDay") == "1" +@pytest.fixture(scope="module") +def ngwmn_private_well(): + """A private well whose child rows are public; nothing may be exported.""" + with session_ctx() as session: + thing = Thing( + name=PRIVATE_POINT_ID, + thing_type="water well", + release_status="private", + well_casing_depth=100.0, + ) + session.add(thing) + session.flush() + + session.add( + WellScreen( + thing_id=thing.id, + screen_depth_top=10.0, + screen_depth_bottom=50.0, + release_status="public", + ) + ) + + formation = GeologicFormation( + formation_code=None, lithology="Sandstone", release_status="public" + ) + session.add(formation) + session.flush() + session.add( + ThingGeologicFormationAssociation( + thing_id=thing.id, + geologic_formation_id=formation.id, + top_depth=0.0, + bottom_depth=50.0, + release_status="public", + ) + ) + + parameter_id = get_parameter_id("groundwater level", "Field Parameter") + + event = FieldEvent( + thing_id=thing.id, + event_date="2024-03-15T19:00:00Z", + release_status="public", + ) + session.add(event) + session.flush() + activity = FieldActivity( + field_event_id=event.id, + activity_type="groundwater level", + release_status="public", + ) + session.add(activity) + session.flush() + sample = Sample( + field_activity_id=activity.id, + sample_date="2024-03-15T19:00:00Z", + sample_name=f"{PRIVATE_POINT_ID}-wl-0", + sample_matrix="water", + sample_method="Steel-tape measurement", + qc_type="Normal", + release_status="public", + ) + session.add(sample) + session.flush() + session.add( + Observation( + sample_id=sample.id, + parameter_id=parameter_id, + observation_datetime="2024-03-15T19:00:00Z", + value=50.0, + unit="ft", + release_status="public", + ) + ) + + sensor = Sensor( + name=f"{PRIVATE_POINT_ID}-transducer", + sensor_type="Pressure Transducer", + release_status="public", + ) + session.add(sensor) + session.flush() + deployment = Deployment( + thing_id=thing.id, + sensor_id=sensor.id, + installation_date="2024-01-01", + release_status="public", + ) + session.add(deployment) + session.flush() + session.add( + TransducerObservation( + deployment_id=deployment.id, + parameter_id=parameter_id, + observation_datetime="2024-03-20T12:00:00Z", + value=30.0, + release_status="public", + ) + ) + session.commit() + thing_id = thing.id + sensor_id = sensor.id + + session.execute(text("REFRESH MATERIALIZED VIEW transducer_daily_data")) + session.commit() + + yield PRIVATE_POINT_ID + + with session_ctx() as session: + session.execute(delete(Thing).where(Thing.id == thing_id)) + session.execute(delete(Sensor).where(Sensor.id == sensor_id)) + session.commit() + session.execute(text("REFRESH MATERIALIZED VIEW transducer_daily_data")) + session.commit() + + +def test_ngwmn_private_thing_exports_nothing(ngwmn_private_well): + for endpoint, root_tag, record_tag in ( + ("waterlevels", "WaterLevels", "WaterLevel"), + ("wellconstruction", "Casings", "Casing"), + ("lithology", "Lithologies", "Lithology"), + ): + response = client.get(f"/ngwmn/{endpoint}/{ngwmn_private_well}") + assert response.status_code == 200 + root = etree.fromstring(response.content) + assert root.tag == root_tag + assert len(root.findall(record_tag)) == 0, endpoint + + def test_ngwmn_unknown_pointid_returns_empty(): response = client.get("/ngwmn/waterlevels/NO-SUCH-POINTID") assert response.status_code == 200 From f63af237371105f167bc4d7fb4694da50d108e37 Mon Sep 17 00:00:00 2001 From: jross Date: Fri, 12 Jun 2026 13:59:21 -0600 Subject: [PATCH 039/160] fix: drop child-row release filters from NGWMN construction/lithology views The release_status predicates on well_screen, well_casing_material, and thing_geologic_formation_association emptied the NGWMN exports on staging: transfers never set release_status on those child tables, so every row is 'draft' (3005/3005 screens, 63/63 casing materials, 993/993 formation associations). Construction and lithology rows are attributes of the well, so the thing-level public filter remains the gate; NGWMN_WaterLevels is unchanged because its field-data chain does carry real release values. Co-Authored-By: Claude Fable 5 --- ..._child_release_filters_from_ngwmn_views.py | 133 ++++++++++++++++++ tests/test_ngwmn_endpoints.py | 11 +- 2 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/w1x2y3z4a5b6_drop_child_release_filters_from_ngwmn_views.py diff --git a/alembic/versions/w1x2y3z4a5b6_drop_child_release_filters_from_ngwmn_views.py b/alembic/versions/w1x2y3z4a5b6_drop_child_release_filters_from_ngwmn_views.py new file mode 100644 index 000000000..5fe815a7c --- /dev/null +++ b/alembic/versions/w1x2y3z4a5b6_drop_child_release_filters_from_ngwmn_views.py @@ -0,0 +1,133 @@ +"""drop child-row release filters from NGWMN construction/lithology views + +The release_status filters added to NGWMN_WellConstruction and +NGWMN_Lithology for well_screen, well_casing_material, and +thing_geologic_formation_association rows emptied those exports in +practice: the transfers never set release_status on those child tables, +so every row defaults to 'draft' (verified 3005/3005 well_screen, +63/63 well_casing_material, 993/993 associations on transferred data). + +Construction and lithology rows are attributes of the well, and the +well's own release_status (genuinely managed: public/private) remains +the gate. The thing-level filters are kept; only the child-row +predicates are removed. NGWMN_WaterLevels is unchanged, since the whole +field-data chain it filters on does carry real release values. + +Revision ID: w1x2y3z4a5b6 +Revises: v0w1x2y3z4a5 +Create Date: 2026-06-12 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "w1x2y3z4a5b6" +down_revision: Union[str, Sequence[str], None] = "v0w1x2y3z4a5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +REQUIRED_TABLES = { + "thing", + "well_screen", + "well_casing_material", + "thing_geologic_formation_association", + "geologic_formation", +} + +DROP_WELLCONSTRUCTION_SQL = 'DROP VIEW IF EXISTS "NGWMN_WellConstruction"' +DROP_LITHOLOGY_SQL = 'DROP VIEW IF EXISTS "NGWMN_Lithology"' + + +def _create_wellconstruction_view(with_child_filters: bool) -> str: + screen_filter = " AND ws.release_status = 'public'" if with_child_filters else "" + material_filter = " AND wcm.release_status = 'public'" if with_child_filters else "" + return f""" + CREATE VIEW "NGWMN_WellConstruction" AS + SELECT + t.name AS "PointID", + CASE WHEN t.well_casing_depth IS NOT NULL THEN 0::double precision END AS "CasingTop", + t.well_casing_depth AS "CasingBottom", + CASE WHEN t.well_casing_depth IS NOT NULL THEN 'ft bgs' END AS "CasingDepthUnits", + ws.screen_depth_top AS "ScreenTop", + ws.screen_depth_bottom AS "ScreenBottom", + CASE WHEN ws.screen_depth_bottom IS NOT NULL THEN 'ft bgs' END AS "ScreenBottomUnit", + ws.screen_description AS "ScreenDescription", + cm.materials AS "CasingDescription" + FROM thing AS t + LEFT JOIN well_screen AS ws + ON ws.thing_id = t.id{screen_filter} + LEFT JOIN LATERAL ( + SELECT string_agg(wcm.material, ', ' ORDER BY wcm.material) AS materials + FROM well_casing_material AS wcm + WHERE wcm.thing_id = t.id{material_filter} + ) AS cm ON TRUE + WHERE t.thing_type = 'water well' + AND t.release_status = 'public' + """ + + +def _create_lithology_view(with_child_filters: bool) -> str: + association_filter = ( + " AND tgfa.release_status = 'public'\n" if with_child_filters else "" + ) + return f""" + CREATE VIEW "NGWMN_Lithology" AS + SELECT + tgfa.id AS "OBJECTID", + t.name AS "PointID", + gf.lithology AS "Lithology", + gf.lithology AS "TERM", + NULL::character varying AS "StratSource", + tgfa.top_depth AS "StratTop", + CASE WHEN tgfa.top_depth IS NOT NULL THEN 'ft bgs' END AS "StratTopUnit", + tgfa.bottom_depth AS "StratBottom", + CASE WHEN tgfa.bottom_depth IS NOT NULL THEN 'ft bgs' END AS "StratBottomUnit" + FROM thing_geologic_formation_association AS tgfa + JOIN thing AS t ON t.id = tgfa.thing_id + JOIN geologic_formation AS gf ON gf.id = tgfa.geologic_formation_id + WHERE gf.lithology IS NOT NULL +{association_filter} AND t.release_status = 'public' + """ + + +def _recreate_views(with_child_filters: bool) -> 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 recreate NGWMN views. Missing required tables: " + f"{', '.join(sorted(missing))}" + ) + + op.execute(text(DROP_WELLCONSTRUCTION_SQL)) + op.execute(text(_create_wellconstruction_view(with_child_filters))) + op.execute( + text( + 'COMMENT ON VIEW "NGWMN_WellConstruction" IS ' + "'Well casing and screen intervals in the NGWMN exchange format, " + "sourced from the Ocotillo thing/well_screen model.'" + ) + ) + + op.execute(text(DROP_LITHOLOGY_SQL)) + op.execute(text(_create_lithology_view(with_child_filters))) + op.execute( + text( + 'COMMENT ON VIEW "NGWMN_Lithology" IS ' + "'Lithology intervals in the NGWMN exchange format, sourced from " + "the Ocotillo geologic formation associations.'" + ) + ) + + +def upgrade() -> None: + _recreate_views(with_child_filters=False) + + +def downgrade() -> None: + _recreate_views(with_child_filters=True) diff --git a/tests/test_ngwmn_endpoints.py b/tests/test_ngwmn_endpoints.py index cf1a6b86b..b36a8992d 100644 --- a/tests/test_ngwmn_endpoints.py +++ b/tests/test_ngwmn_endpoints.py @@ -61,25 +61,28 @@ def ngwmn_well(): session.add(thing) session.flush() + # Screen, casing material, and lithology rows are deliberately left at + # the 'draft' default: transfers never set release_status on these + # child tables, and the export is gated on the thing's status alone. session.add( WellScreen( thing_id=thing.id, screen_depth_top=80.0, screen_depth_bottom=120.0, screen_description="4in slotted", - release_status="public", + release_status="draft", ) ) session.add( WellCasingMaterial( - thing_id=thing.id, material="Steel", release_status="public" + thing_id=thing.id, material="Steel", release_status="draft" ) ) formation = GeologicFormation( formation_code=None, lithology="Sandstone", - release_status="public", + release_status="draft", ) session.add(formation) session.flush() @@ -89,7 +92,7 @@ def ngwmn_well(): geologic_formation_id=formation.id, top_depth=0.0, bottom_depth=60.0, - release_status="public", + release_status="draft", ) ) From e2cd8fc20a43e0dfc23793109ff2c900c2cc395b Mon Sep 17 00:00:00 2001 From: jross Date: Fri, 12 Jun 2026 14:37:54 -0600 Subject: [PATCH 040/160] docs: clarify draft release_status comment in NGWMN test fixture Co-Authored-By: Claude Fable 5 --- tests/test_ngwmn_endpoints.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_ngwmn_endpoints.py b/tests/test_ngwmn_endpoints.py index b36a8992d..b78d84fce 100644 --- a/tests/test_ngwmn_endpoints.py +++ b/tests/test_ngwmn_endpoints.py @@ -61,9 +61,10 @@ def ngwmn_well(): session.add(thing) session.flush() - # Screen, casing material, and lithology rows are deliberately left at - # the 'draft' default: transfers never set release_status on these - # child tables, and the export is gated on the thing's status alone. + # Screen, casing material, and lithology rows are explicitly set to + # 'draft' to mirror transferred data, where these child tables never + # get a release_status. The export is gated on the thing's status + # alone, so these rows must still appear in the NGWMN responses. session.add( WellScreen( thing_id=thing.id, From 50b0faeb462aad41bb024957c38058eb0928592c Mon Sep 17 00:00:00 2001 From: jross Date: Fri, 12 Jun 2026 14:54:53 -0600 Subject: [PATCH 041/160] fix: export daily minimum depth for NGWMN transducer water levels The legacy NMA_WaterLevelsContinuous_Pressure_Daily values are the shallowest reading of each day (verified min matches 1460/1460 days of raw SO-0252 data; the mean matches none), so exporting the daily mean shifted every published transducer value deeper by ~0.01-0.33 ft. Select depth_to_water_bgs_min from transducer_daily_data instead to keep the NGWMN record consistent with the historically harvested data. Co-Authored-By: Claude Fable 5 --- services/ngwmn_helper.py | 6 +++++- tests/test_ngwmn_endpoints.py | 13 +++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/services/ngwmn_helper.py b/services/ngwmn_helper.py index d164507f1..95cf127bd 100644 --- a/services/ngwmn_helper.py +++ b/services/ngwmn_helper.py @@ -79,11 +79,15 @@ def make_waterlevels_response(point_id, db): .order_by(NGWMNWaterLevels.date_measured) .all() ) + # The daily *minimum* depth matches the legacy + # NMA_WaterLevelsContinuous_Pressure_Daily values (AMP's nightly job + # published the shallowest reading of each day), keeping the NGWMN + # record consistent with what was historically harvested. pressure = ( db.query( TransducerDailyData.point_id, TransducerDailyData.date_measured, - TransducerDailyData.depth_to_water_bgs, + TransducerDailyData.depth_to_water_bgs_min, ) .join(Thing, Thing.id == TransducerDailyData.thing_id) .filter( diff --git a/tests/test_ngwmn_endpoints.py b/tests/test_ngwmn_endpoints.py index b78d84fce..c2362dab0 100644 --- a/tests/test_ngwmn_endpoints.py +++ b/tests/test_ngwmn_endpoints.py @@ -263,13 +263,13 @@ def ngwmn_merged_well(): session.flush() transducer_readings = [ - # 2024-03-15 daily avg 50.0: manual 47.50 is shallower and wins. + # 2024-03-15 daily min 49.0: manual 47.50 is shallower and wins. ("2024-03-15T06:00:00Z", 49.0), ("2024-03-15T18:00:00Z", 51.0), - # 2024-03-20 daily avg 30.0: transducer-only date. + # 2024-03-20 daily min 29.0: transducer-only date. ("2024-03-20T06:00:00Z", 29.0), ("2024-03-20T18:00:00Z", 31.0), - # 2024-04-01 daily avg 20.0: manual 33.00 is deeper and loses. + # 2024-04-01 daily min 19.0: manual 33.00 is deeper and loses. ("2024-04-01T06:00:00Z", 19.0), ("2024-04-01T18:00:00Z", 21.0), ] @@ -380,8 +380,9 @@ def test_ngwmn_waterlevels_merges_manual_and_transducer(ngwmn_merged_well): assert first.findtext("MeasurementMonth") == "3" assert first.findtext("MeasurementDay") == "15" - # Transducer-only date: daily average is emitted. - assert second.findtext("DepthFromLandSurfaceData") == "30.00" + # Transducer-only date: the daily minimum is emitted, matching the + # legacy NMA_WaterLevelsContinuous_Pressure_Daily statistic. + assert second.findtext("DepthFromLandSurfaceData") == "29.00" assert second.findtext("MeasuringMethod") == "Pressure Transducer" assert second.findtext("WaterLevelUnits") == "ft bgs" assert second.findtext("WaterLevelAccuracy") == "0.02 ft" @@ -390,7 +391,7 @@ def test_ngwmn_waterlevels_merges_manual_and_transducer(ngwmn_merged_well): # Same-date overlap where the manual reading is deeper: transducer wins # and the manual record is dropped. - assert third.findtext("DepthFromLandSurfaceData") == "20.00" + assert third.findtext("DepthFromLandSurfaceData") == "19.00" assert third.findtext("MeasuringMethod") == "Pressure Transducer" assert third.findtext("MeasurementMonth") == "4" assert third.findtext("MeasurementDay") == "1" From dc82d4b917930dff569262861464f9397607ae73 Mon Sep 17 00:00:00 2001 From: jross Date: Fri, 12 Jun 2026 15:36:36 -0600 Subject: [PATCH 042/160] ci: inject feedback endpoint config from Google Secret Manager Fetch jira-email, jira-api-token, and slack-feedback-webhook-url from Secret Manager in all three deploy workflows and render them into the App Engine config alongside JIRA_BASE_URL / JIRA_DEFAULT_PROJECT repo vars (with sensible defaults). The deploy service account needs roles/secretmanager.secretAccessor on these secrets in each project. Co-Authored-By: Claude Fable 5 --- .github/app.template.yaml | 7 +++++++ .github/workflows/CD_production.yml | 17 +++++++++++++++++ .github/workflows/CD_staging.yml | 17 +++++++++++++++++ .github/workflows/CD_testing.yml | 17 +++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/.github/app.template.yaml b/.github/app.template.yaml index 619ba4cc5..d3eb23ab0 100644 --- a/.github/app.template.yaml +++ b/.github/app.template.yaml @@ -38,3 +38,10 @@ env_variables: SESSION_SECRET_KEY: |- ${SESSION_SECRET_KEY} APITALLY_CLIENT_ID: "${APITALLY_CLIENT_ID}" + JIRA_BASE_URL: "${JIRA_BASE_URL}" + JIRA_EMAIL: "${JIRA_EMAIL}" + JIRA_API_TOKEN: |- + ${JIRA_API_TOKEN} + JIRA_DEFAULT_PROJECT: "${JIRA_DEFAULT_PROJECT}" + SLACK_FEEDBACK_WEBHOOK_URL: |- + ${SLACK_FEEDBACK_WEBHOOK_URL} diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index e135876e1..148120724 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -68,6 +68,18 @@ 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 + uses: 'google-github-actions/get-secretmanager-secrets@v2' + with: + secrets: |- + jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email + jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token + slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url + - name: Run Alembic migrations on production database env: DB_DRIVER: "cloudsql" @@ -117,6 +129,11 @@ jobs: 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 }}" + JIRA_API_TOKEN: "${{ steps.feedback-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 }}" run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api" diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 5b14854f9..3a38dddbd 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -36,6 +36,18 @@ 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 + uses: 'google-github-actions/get-secretmanager-secrets@v2' + with: + secrets: |- + jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email + jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token + slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url + - name: Run Alembic migrations on staging database env: DB_DRIVER: "cloudsql" @@ -85,6 +97,11 @@ jobs: 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 }}" + JIRA_API_TOKEN: "${{ steps.feedback-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 }}" run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api-staging" diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index ff58a2d31..6150195e9 100644 --- a/.github/workflows/CD_testing.yml +++ b/.github/workflows/CD_testing.yml @@ -36,6 +36,18 @@ 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 + uses: 'google-github-actions/get-secretmanager-secrets@v2' + with: + secrets: |- + jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email + jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token + slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url + - name: Run Alembic migrations on staging database env: DB_DRIVER: "cloudsql" @@ -85,6 +97,11 @@ jobs: 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 }}" + JIRA_API_TOKEN: "${{ steps.feedback-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 }}" run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api-testing" From bb9f2cc8e81cb2ccdf552bd7e4d6dedcb179f425 Mon Sep 17 00:00:00 2001 From: Peter Rowland Date: Fri, 12 Jun 2026 15:24:08 -0700 Subject: [PATCH 043/160] exporting NM_Wells csv files -generate csv files via script -fix some data overflow problems --- pyproject.toml | 1 + transfers/export_nmw_csvs.py | 86 ++++++++++++++++++++++++++++++++ transfers/nmw_mirror_transfer.py | 13 ++++- uv.lock | 26 +++++++++- 4 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 transfers/export_nmw_csvs.py diff --git a/pyproject.toml b/pyproject.toml index 3feae3128..05e26ce4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,7 @@ dependencies = [ "utm==0.8.1", "uvicorn==0.49.0", "yarl==1.24.2", + "pymssql>=2.3.13", ] [tool.uv] diff --git a/transfers/export_nmw_csvs.py b/transfers/export_nmw_csvs.py new file mode 100644 index 000000000..e2cf438b3 --- /dev/null +++ b/transfers/export_nmw_csvs.py @@ -0,0 +1,86 @@ +"""Export NM_Wells SQL Server tables to CSV files for the transfer pipeline. + +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 +looks for them when NMW_SQL_DUMP is not set. + +Usage: + uv run python -m transfers.export_nmw_csvs + +Required environment variables (add to .env): + NMW_HOST SQL Server hostname or IP + NMW_PORT SQL Server port (default: 1433) + NMW_USER SQL Server username + NMW_PASSWORD SQL Server password + NMW_DATABASE Database name (default: NM_Wells) +""" + +import os +from pathlib import Path + +import pymssql +from dotenv import load_dotenv + +from transfers.nmw_mirror_transfer import NMW_MIRROR_SPECS + +load_dotenv(override=False) + +TABLES = [spec.source_table for spec in NMW_MIRROR_SPECS] + +OUT_DIR = Path(__file__).parent / "data" / "nma_csv_cache" + + +def _get_connection(): + host = os.environ["NMW_HOST"] + port = int(os.environ.get("NMW_PORT", 1433)) + user = os.environ["NMW_USER"] + password = os.environ["NMW_PASSWORD"] + database = os.environ.get("NMW_DATABASE", "NM_Wells") + return pymssql.connect( + server=host, + port=port, + user=user, + password=password, + database=database, + ) + + +def export_table(cursor, table: str, out_path: Path) -> int: + cursor.execute(f"SELECT * FROM dbo.{table}") + columns = [desc[0] for desc in cursor.description] + rows = cursor.fetchall() + + import csv + + with out_path.open("w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(columns) + writer.writerows(rows) + + return len(rows) + + +def main(): + OUT_DIR.mkdir(parents=True, exist_ok=True) + print( + f"Connecting to {os.environ.get('NMW_HOST')} / {os.environ.get('NMW_DATABASE', 'NM_Wells')}" + ) + conn = _get_connection() + cursor = conn.cursor() + + for table in TABLES: + out_path = OUT_DIR / f"{table}.csv" + print(f" Exporting {table}...", end=" ", flush=True) + try: + n = export_table(cursor, table, out_path) + print(f"{n} rows -> {out_path.name}") + except Exception as e: + print(f"FAILED: {e}") + + cursor.close() + conn.close() + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/transfers/nmw_mirror_transfer.py b/transfers/nmw_mirror_transfer.py index d541d0f0e..18f930e13 100644 --- a/transfers/nmw_mirror_transfer.py +++ b/transfers/nmw_mirror_transfer.py @@ -84,6 +84,9 @@ # Optional output dir for the per-table CSVs written from the dump (COPY path). # Defaults to a fresh temp dir. _CSV_DIR_ENV = "NMW_CSV_DIR" +# pg8000 encodes the parameter count as an unsigned short (max 65535). +# Keep chunk size below that ceiling based on actual column count per table. +_MAX_PG8000_PARAMS = 65535 _CHUNK_SIZE = 2000 # Materialized OGC views over the geothermal mirror that need a REFRESH after a @@ -248,6 +251,7 @@ def _load_table(session: Session, spec: MirrorSpec, limit: int = 0) -> dict: if limit and limit > 0: rows_iter = itertools.islice(rows_iter, limit) + chunk_size = min(_CHUNK_SIZE, _MAX_PG8000_PARAMS // max(len(cols), 1)) total = 0 inserted = 0 batch: list[dict] = [] @@ -266,7 +270,7 @@ def _load_table(session: Session, spec: MirrorSpec, limit: int = 0) -> dict: if any(row.get(pk) is None for pk in pk_cols): continue # cannot upsert without a PK value batch.append(row) - if len(batch) >= _CHUNK_SIZE: + if len(batch) >= chunk_size: inserted += _flush(session, spec.model, batch, pk_cols) batch = [] inserted += _flush(session, spec.model, batch, pk_cols) @@ -347,7 +351,12 @@ def refresh_materialized_views(session: Session) -> list[str]: refreshed = [] for view in _MATERIALIZED_VIEWS: exists = session.execute( - text("SELECT to_regclass(:n)"), {"n": f"public.{view}"} + text( + "SELECT EXISTS(" + "SELECT 1 FROM pg_matviews WHERE schemaname='public' AND matviewname=:n" + ")" + ), + {"n": view}, ).scalar() if not exists: logger.warning("Skip refresh; materialized view missing: %s", view) diff --git a/uv.lock b/uv.lock index a56136a1a..129add870 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -1560,6 +1560,7 @@ dependencies = [ { name = "pygeoapi" }, { name = "pygments" }, { name = "pyjwt" }, + { name = "pymssql" }, { name = "pyproj" }, { name = "pyshp" }, { name = "python-dateutil" }, @@ -1674,6 +1675,7 @@ requires-dist = [ { name = "pygeoapi", specifier = "==0.23.4" }, { name = "pygments", specifier = "==2.20.0" }, { name = "pyjwt", specifier = "==2.13.0" }, + { name = "pymssql", specifier = ">=2.3.13" }, { name = "pyproj", specifier = "==3.7.2" }, { name = "pyshp", specifier = "==2.3.1" }, { name = "python-dateutil", specifier = "==2.9.0.post0" }, @@ -2313,6 +2315,28 @@ 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]] +name = "pymssql" +version = "2.3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/cc/843c044b7f71ee329436b7327c578383e2f2499313899f88ad267cdf1f33/pymssql-2.3.13.tar.gz", hash = "sha256:2137e904b1a65546be4ccb96730a391fcd5a85aab8a0632721feb5d7e39cfbce", size = 203153, upload-time = "2026-02-14T05:00:36.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/4f/ee15b1f6b11e7c3accdc7da7840a019b63f12ba09eaa008acc601182f516/pymssql-2.3.13-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:30918bb044242865c01838909777ef5e0f1b9ecd7f5882346aefa57f4414b29c", size = 3156333, upload-time = "2026-02-14T05:00:01.21Z" }, + { url = "https://files.pythonhosted.org/packages/79/03/aea5c77bad4a52649a1d9f786a1d9ce1c83d50f1a75df288e292737b6d80/pymssql-2.3.13-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1c6d0b2d7961f159a07e4f0d8cc81f70ceab83f5e7fd1e832a2d069e1d67ee4e", size = 2957990, upload-time = "2026-02-14T05:00:03.11Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f8/30ac16fba32ff066b05f12c392d7b812fe11f06cb62d1d86ca5177c50a8b/pymssql-2.3.13-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16c5957a3c9e51a03276bfd76a22431e2bc4c565e2e95f2cbb3559312edda230", size = 3065264, upload-time = "2026-02-14T05:00:05.377Z" }, + { url = "https://files.pythonhosted.org/packages/a9/98/7568447bf85921d21453fd56e19b6c9591d595fde0546c5a569f3ae937a8/pymssql-2.3.13-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fddd24efe9d18bbf174fab7c6745b0927773718387f5517cf8082241f721a68", size = 3190039, upload-time = "2026-02-14T05:00:06.925Z" }, + { url = "https://files.pythonhosted.org/packages/35/f1/4d9d275ebaac42cdd49d40d504ccb648f27710660c8b60cc427752438c09/pymssql-2.3.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:123c55ee41bc7a82c76db12e2eb189b50d0d7a11222b4f8789206d1cda3b33b9", size = 3710151, upload-time = "2026-02-14T05:00:08.424Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bd/a5cc6244fd27d3ea0cc82f12a7d38a24d7fd90b0022afd250014e8bfba15/pymssql-2.3.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e053b443e842f9e1698fcb2b23a4bff1ff3d410894d880064e754ad823d541e5", size = 3453156, upload-time = "2026-02-14T05:00:09.978Z" }, + { url = "https://files.pythonhosted.org/packages/26/d0/c20ff0bbffd18db528bcc7b0c68b25c12ad563ed67c56ceca87c58f7399e/pymssql-2.3.13-cp313-cp313-win_amd64.whl", hash = "sha256:5c045c0f1977a679cc30d5acd9da3f8aeb2dc6e744895b26444b4a2f20dad9a0", size = 1995236, upload-time = "2026-02-14T05:00:11.495Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5f/6b64f78181d680f655ab40ba7b34cb68c045a2f4e04a10a70d768cd383b7/pymssql-2.3.13-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fc5482969c813b0a45ce51c41844ae5bfa8044ad5ef8b4820ef6de7d4545b7f2", size = 3158377, upload-time = "2026-02-14T05:00:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/ff/24/155dbb0992c431496d440f47fb9d587cd0059ee20baf65e3d891794d862a/pymssql-2.3.13-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:ff5be7ab1d643dbce2ee3424d2ef9ae8e4146cf75bd20946bc7a6108e3ad1e47", size = 2959039, upload-time = "2026-02-14T05:00:15.883Z" }, + { url = "https://files.pythonhosted.org/packages/c9/89/b453dd1b1188779621fb974ac715ab2e738f4a0b69f7291ab014298bd80d/pymssql-2.3.13-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d66ce0a249d2e3b57369048d71e1f00d08dfb90a758d134da0250ae7bc739c1", size = 3063862, upload-time = "2026-02-14T05:00:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/02/e5/96f57c78162013678ecc3f3f7e5fb52c83ee07beef26906d0870770c3ef6/pymssql-2.3.13-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d663c908414a6a032f04d17628138b1782af916afc0df9fefac4751fa394c3ac", size = 3188155, upload-time = "2026-02-14T05:00:19.011Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a2/4bee9484734ae0c55d10a2f6ff82dd4e416f52420755161b8760c817ad64/pymssql-2.3.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aa5e07eff7e6e8bd4ba22c30e4cb8dd073e138cd272090603609a15cc5dbc75b", size = 3709344, upload-time = "2026-02-14T05:00:21.139Z" }, + { url = "https://files.pythonhosted.org/packages/37/cf/3520d96afa213c88db4f4a1988199db476d869a62afdd5d9c4635c184631/pymssql-2.3.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db77da1a3fc9b5b5c5400639d79d7658ba7ad620957100c5b025be608b562193", size = 3451799, upload-time = "2026-02-14T05:00:22.504Z" }, + { url = "https://files.pythonhosted.org/packages/25/50/4be9bd9cf4b43208a7175117a533ece200cfe4131a39f9909bdc7560ddeb/pymssql-2.3.13-cp314-cp314-win_amd64.whl", hash = "sha256:7d7037d2b5b907acc7906d0479924db2935a70c720450c41339146a4ada2b93d", size = 2049139, upload-time = "2026-02-14T05:00:23.951Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" From 29c05a7774faef837a2ff4a919e6e9e26fe51b0e Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Sat, 13 Jun 2026 14:13:46 -0400 Subject: [PATCH 044/160] Add well counts and project filters for the Projects view. Groups list responses now include well counts, and the wells list can filter and sort by linked projects through the virtual groups field. --- api/group.py | 14 ++- .../refine-json-filters-and-virtual-fields.md | 4 + schemas/group.py | 1 + services/group_helper.py | 65 ++++++++++ services/query_helper.py | 111 ++++++++++++++++++ services/thing_helper.py | 1 + tests/test_group.py | 53 ++++++++- tests/test_thing.py | 61 ++++++++++ 8 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 services/group_helper.py diff --git a/api/group.py b/api/group.py index 5399ce103..962870c1e 100644 --- a/api/group.py +++ b/api/group.py @@ -27,10 +27,12 @@ from db.group import Group from schemas.group import UpdateGroup, CreateGroup, GroupResponse from services.crud_helper import model_patcher, model_deleter, model_adder -from services.query_helper import ( - simple_get_by_id, - paginated_all_getter, +from services.group_helper import ( + get_well_counts_by_group_id, + group_to_response, + paginated_groups_getter, ) +from services.query_helper import simple_get_by_id router = APIRouter(prefix="/group", tags=["group"]) @@ -74,7 +76,7 @@ def get_groups( """ Retrieve all groups from the database. """ - return paginated_all_getter(session, Group, filter_=filter_) + return paginated_groups_getter(session, filter_=filter_) @router.get("/{group_id}", summary="Get group by ID") @@ -84,7 +86,9 @@ def get_group_by_id( """ Retrieve a group by ID from the database. """ - return simple_get_by_id(session, Group, group_id) + group = simple_get_by_id(session, Group, group_id) + counts = get_well_counts_by_group_id(session, [group.id]) + return group_to_response(group, counts.get(group.id, 0)) # @router.get( diff --git a/docs/refine-json-filters-and-virtual-fields.md b/docs/refine-json-filters-and-virtual-fields.md index e00a7c209..cd393cdcf 100644 --- a/docs/refine-json-filters-and-virtual-fields.md +++ b/docs/refine-json-filters-and-virtual-fields.md @@ -38,6 +38,7 @@ Associations are stored in **`ThingContactAssociation`** (`thing_id`, `contact_i | List resource | Virtual `field` | Meaning | Implementation sketch | |---------------|------------------|---------|------------------------| | Thing (wells) | `contacts` | “Does **any** linked contact’s **name** match?” | EXISTS over `ThingContactAssociation` joining `Contact`, predicate on **`Contact.name`** | +| Thing (wells) | `groups` | “Does **any** linked project (**Group**) match?” | EXISTS over `GroupThingAssociation` joining `Group`, predicate on **`Group.id`** or **`Group.name`** | | Contact | `things` | “Does **any** linked monitoring site (**thing**) **name** match?” | EXISTS over **`ThingContactAssociation`** joining **`Thing`**, predicate on **`Thing.name`** | We keep naming aligned with ORM accessors (`Thing.contacts`-style summaries in API responses use **contacts**, and **`Contact`** side uses **`things`** for parity with the association proxy). @@ -77,6 +78,7 @@ Those paths previously raised **500**. Virtual sorts are implemented in **`_appl | `monitoring_status`, `well_status`, `datalogger_suitability_status` | Same “latest open” **`StatusHistory.status_value`** subquery as filters; **`lower(...)`**, **`nulls_last`** | | `site_name` | **`ThingIdLink.alternate_id`** where **`alternate_organization = 'NMBGMR'`**, smallest link **`id`** (matches **`Thing.site_name`**) | | `contacts` | **`min(lower(Contact.name))`** over **`ThingContactAssociation`** (first name alphabetically among linked contacts) | +| `groups` | **`min(lower(Group.name))`** over **`GroupThingAssociation`** (first project name alphabetically among linked groups) | | `aquifers` | **`min(lower(AquiferSystem.name))`** over **`ThingAquiferAssociation`** | | `open_status` | Latest open **“Open Status”** row; rank **Open** before **Closed**, then unknown strings, then no row | | `measuring_point_height` | Latest **`MeasuringPointHistory`** row with non-null height (**`start_date` desc**, limit 1) | @@ -104,6 +106,7 @@ Each filter **must** include **`field`**, **`operator`**, and **`value`** keys ( | Merge **`filter_`** + **`filters`**, sorting, pagination hook | **`order_sort_filter`** in **`services/query_helper.py`** | | Dispatch virtual fields | **`_apply_json_filter_clause`** in **`services/query_helper.py`** | | **`Thing` + contacts** | **`_apply_thing_contacts_filter`** | +| **`Thing` + groups** | **`_apply_thing_groups_filter`** | | **`Contact` + things** | **`_apply_contact_things_filter`** | | Contact list accepts repeated **`filter`** | **`GET`** **`/contact`** in **`api/contact.py`**, **`get_db_contacts`** in **`services/contact_helper.py`** | | Wells list pattern (reference) | **`GET`** **`/thing/water-well`** in **`api/thing.py`**, **`get_db_things`** in **`services/thing_helper.py`** | @@ -113,6 +116,7 @@ Each filter **must** include **`field`**, **`operator`**, and **`value`** keys ( - **`tests/test_contact_filters.py`**: **`things`** filters, **`things`** sort, multiple **`filter`** params on **`GET /contact`**. - **`tests/test_thing.py`** (contacts on wells): **`contacts`** **`contains`**, **`ncontains`**, **`nnull`**, and **`sort`** on **`monitoring_status`**, **`site_name`**, **`contacts`**, **`aquifers`**, etc. +- **`tests/test_thing.py`** (groups on wells): **`groups`** **`eq`** by project id or name when filtering wells by project. ## When you change this diff --git a/schemas/group.py b/schemas/group.py index e3cc7488c..2472dc0fa 100644 --- a/schemas/group.py +++ b/schemas/group.py @@ -58,6 +58,7 @@ class GroupResponse(BaseResponseModel): project_area: str | None group_type: GroupType | None parent_group_id: int | None + well_count: int = 0 @model_validator(mode="before") def project_area_to_wkt(self: Self) -> Self: diff --git a/services/group_helper.py b/services/group_helper.py new file mode 100644 index 000000000..b81dd81c2 --- /dev/null +++ b/services/group_helper.py @@ -0,0 +1,65 @@ +# =============================================================================== +# Copyright 2025 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 typing import Any + +from fastapi_pagination.ext.sqlalchemy import paginate +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from db.group import Group, GroupThingAssociation +from db.thing import Thing +from schemas.group import GroupResponse +from services.query_helper import order_sort_filter + + +def get_well_counts_by_group_id( + session: Session, group_ids: list[int] +) -> dict[int, int]: + if not group_ids: + return {} + + stmt = ( + select( + GroupThingAssociation.group_id, + func.count(Thing.id), + ) + .join(Thing, GroupThingAssociation.thing_id == Thing.id) + .where(GroupThingAssociation.group_id.in_(group_ids)) + .where(Thing.thing_type == "water well") + .group_by(GroupThingAssociation.group_id) + ) + return {row[0]: int(row[1]) for row in session.execute(stmt).all()} + + +def group_to_response(group: Group, well_count: int = 0) -> GroupResponse: + response = GroupResponse.model_validate(group) + return response.model_copy(update={"well_count": well_count}) + + +def paginated_groups_getter( + session: Session, + filter_: str | None = None, + *, + filters: list[str] | None = None, +) -> Any: + sql = select(Group) + sql = order_sort_filter(sql, Group, None, None, filter_, filters=filters) + + def transformer(groups: list[Group]) -> list[GroupResponse]: + counts = get_well_counts_by_group_id(session, [group.id for group in groups]) + return [group_to_response(group, counts.get(group.id, 0)) for group in groups] + + return paginate(query=sql, conn=session, transformer=transformer) diff --git a/services/query_helper.py b/services/query_helper.py index 538e93a0e..aeb142273 100644 --- a/services/query_helper.py +++ b/services/query_helper.py @@ -343,6 +343,25 @@ def _thing_contacts_min_name_sort_scalar(thing_table: type): ) +def _thing_groups_min_name_sort_scalar(thing_table: type): + """Minimum ``lower(Group.name)`` across linked projects (stable proxy for display order).""" + from db.group import Group, GroupThingAssociation + + gta = GroupThingAssociation + g = Group + return ( + select(func.min(func.lower(g.name))) + .select_from(gta) + .join(g, gta.group_id == g.id) + .where( + gta.thing_id == thing_table.id, + g.name.isnot(None), + ) + .correlate(thing_table) + .scalar_subquery() + ) + + def _thing_aquifers_min_name_sort_scalar(thing_table: type): """Minimum ``lower(AquiferSystem.name)`` across linked aquifers.""" from db.aquifer_system import AquiferSystem @@ -417,6 +436,7 @@ def _contact_things_min_name_sort_scalar(contact_table: type): "datalogger_suitability_status", "site_name", "contacts", + "groups", "aquifers", "open_status", "measuring_point_height", @@ -486,6 +506,9 @@ def num_order(expr): if sort == "contacts": return str_order(_thing_contacts_min_name_sort_scalar(thing_table)) + if sort == "groups": + return str_order(_thing_groups_min_name_sort_scalar(thing_table)) + if sort == "aquifers": return str_order(_thing_aquifers_min_name_sort_scalar(thing_table)) @@ -610,6 +633,91 @@ def _linked_contact_select(predicate): return sql.where(exists(_linked_contact_select(pred))) +def _apply_thing_groups_filter( + sql: Select[Any], + thing_table: type, + operator: str, + value: Any, +) -> Select[Any]: + """Filter ``Thing`` rows using linked groups / projects (many-to-many). + + Refine sends ``field=groups`` from the wells list when filtering by project. + Match **any** linked ``Group`` by id (numeric ``eq``) or by ``Group.name``. + """ + from db.group import Group, GroupThingAssociation + + gta = GroupThingAssociation + g = Group + + def _linked_group_select(predicate): + return ( + select(1) + .select_from(gta) + .join(g, gta.group_id == g.id) + .where( + gta.thing_id == thing_table.id, + predicate, + ) + ) + + any_linked_group = ( + select(1) + .select_from(gta) + .join(g, gta.group_id == g.id) + .where(gta.thing_id == thing_table.id) + ) + + if operator == "nnull": + return sql.where(exists(any_linked_group)) + + if operator == "null": + return sql.where(~exists(any_linked_group)) + + if operator == "eq": + + def _eq_predicate(): + try: + group_id = int(value) + return g.id == group_id + except (TypeError, ValueError): + return g.name == str(value) + + return sql.where(exists(_linked_group_select(_eq_predicate()))) + + if operator == "ne": + + def _ne_predicate(): + try: + group_id = int(value) + return g.id == group_id + except (TypeError, ValueError): + return g.name == str(value) + + return sql.where(~exists(_linked_group_select(_ne_predicate()))) + + if operator == "ncontains": + nlg = _linked_group_select(g.name.ilike(f"%{value}%")) + return sql.where(~exists(nlg)) + + if operator == "contains": + pred = g.name.ilike(f"%{value}%") + elif operator == "startswith": + pred = g.name.ilike(f"{value}%") + elif operator == "endswith": + pred = g.name.ilike(f"%{value}") + else: + raise HTTPException( + status_code=400, + detail=( + f"Operator {operator!r} is not supported for groups " + "filters (contains, ncontains, eq, ne, startswith, endswith, " + "null, nnull)" + ), + ) + + return sql.where(exists(_linked_group_select(pred))) + + def _apply_contact_things_filter( sql: Select[Any], contact_table: type, @@ -739,6 +847,9 @@ def _apply_json_filter_clause( if getattr(table, "__name__", None) == "Thing" and field == "contacts": return _apply_thing_contacts_filter(sql, table, operator, value) + if getattr(table, "__name__", None) == "Thing" and field == "groups": + return _apply_thing_groups_filter(sql, table, operator, value) + try: column = getattr(table, field) except AttributeError as exc: diff --git a/services/thing_helper.py b/services/thing_helper.py index 16fdd9a6a..c75000450 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -71,6 +71,7 @@ def is_debug_timing_enabled() -> bool: selectinload(Thing.contact_associations).selectinload( ThingContactAssociation.contact ), + selectinload(Thing.group_associations).selectinload(GroupThingAssociation.group), selectinload(Thing.well_purposes), selectinload(Thing.well_casing_materials), selectinload(Thing.links), diff --git a/tests/test_group.py b/tests/test_group.py index d703b0bd5..de4c6672a 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -5,7 +5,8 @@ from pydantic import ValidationError from core.dependencies import admin_function, viewer_function, editor_function -from db import Group +from db import Group, GroupThingAssociation, Thing +from db.engine import session_ctx from main import app from schemas import DT_FMT from schemas.group import ValidateGroup @@ -103,6 +104,55 @@ def test_get_groups(group): assert data["items"][0]["project_area"] == to_shape(group.project_area).wkt assert data["items"][0]["description"] == group.description assert data["items"][0]["parent_group_id"] == group.parent_group_id + assert data["items"][0]["well_count"] == 1 + + +def test_get_groups_well_count_excludes_non_water_wells( + group, water_well_thing, location, spring_thing +): + with session_ctx() as session: + second_well = Thing( + name="Second Test Well", + first_visit_date="2023-03-03", + thing_type="water well", + release_status="draft", + well_depth=10, + hole_depth=10, + well_casing_diameter=5.0, + well_casing_depth=10.0, + ) + session.add(second_well) + session.commit() + session.refresh(second_well) + + for thing_id in (second_well.id, spring_thing.id): + session.add(GroupThingAssociation(group_id=group.id, thing_id=thing_id)) + session.commit() + + response = client.get("/group") + assert response.status_code == 200 + data = response.json() + item = next(item for item in data["items"] if item["id"] == group.id) + assert item["well_count"] == 2 + + +def test_get_groups_well_count_zero_without_associations(): + payload = { + "release_status": "private", + "name": "Empty Project Group", + "description": "No associated wells.", + } + create_response = client.post("/group", json=payload) + assert create_response.status_code == 201 + group_id = create_response.json()["id"] + + response = client.get("/group") + assert response.status_code == 200 + data = response.json() + item = next(item for item in data["items"] if item["id"] == group_id) + assert item["well_count"] == 0 + + cleanup_post_test(Group, group_id) def test_get_group_by_id(group): @@ -118,6 +168,7 @@ def test_get_group_by_id(group): assert data["description"] == group.description assert data["parent_group_id"] == group.parent_group_id assert data["release_status"] == group.release_status + assert data["well_count"] == 1 def test_get_group_by_id_404_not_found(group): diff --git a/tests/test_thing.py b/tests/test_thing.py index d3444a7c2..8c8859c44 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -985,6 +985,67 @@ def test_get_water_wells_filter_contacts_nnull(water_well_thing, contact): assert water_well_thing.id in ids +def test_get_water_wells_filter_groups_eq_by_id(group, water_well_thing): + fl = json.dumps( + {"field": "groups", "operator": "eq", "value": str(group.id)}, + ) + response = client.get("/thing/water-well", params=[("filter", fl)]) + assert response.status_code == 200 + data = response.json() + ids = [item["id"] for item in data["items"]] + assert water_well_thing.id in ids + + +def test_get_water_wells_filter_groups_eq_by_id_no_match(group, water_well_thing): + fl = json.dumps( + {"field": "groups", "operator": "eq", "value": "999999"}, + ) + response = client.get("/thing/water-well", params=[("filter", fl)]) + assert response.status_code == 200 + data = response.json() + ids = [item["id"] for item in data["items"]] + assert water_well_thing.id not in ids + + +def test_get_water_wells_filter_groups_eq_by_name(group, water_well_thing): + fl = json.dumps( + {"field": "groups", "operator": "eq", "value": group.name}, + ) + response = client.get("/thing/water-well", params=[("filter", fl)]) + assert response.status_code == 200 + data = response.json() + ids = [item["id"] for item in data["items"]] + assert water_well_thing.id in ids + + +def test_get_water_wells_filter_groups_unsupported_operator(group): + fl = json.dumps( + {"field": "groups", "operator": "gt", "value": group.id}, + ) + response = client.get("/thing/water-well", params=[("filter", fl)]) + assert response.status_code == 400 + + +def test_get_water_wells_list_includes_groups(group, water_well_thing): + response = client.get("/thing/water-well", params={"page": 1, "size": 50}) + assert response.status_code == 200 + data = response.json() + well = next(item for item in data["items"] if item["id"] == water_well_thing.id) + assert len(well["groups"]) >= 1 + assert well["groups"][0]["name"] == group.name + + +def test_get_water_wells_sort_groups_asc(group, water_well_thing): + response = client.get( + "/thing/water-well", + params={ + "sort": "groups", + "order": "asc", + }, + ) + assert response.status_code == 200 + + def test_get_water_wells_sort_monitoring_status_desc(water_well_thing): """Derived status columns are Python properties; sort uses StatusHistory SQL.""" response = client.get( From 7735ebeb6ca9735ec3969e2b4f8be8a20d036e5d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:07:54 +0000 Subject: [PATCH 045/160] build(deps): bump google-github-actions/get-secretmanager-secrets Bumps [google-github-actions/get-secretmanager-secrets](https://github.com/google-github-actions/get-secretmanager-secrets) from 2 to 3. - [Release notes](https://github.com/google-github-actions/get-secretmanager-secrets/releases) - [Changelog](https://github.com/google-github-actions/get-secretmanager-secrets/blob/main/CHANGELOG.md) - [Commits](https://github.com/google-github-actions/get-secretmanager-secrets/compare/v2...v3) --- updated-dependencies: - dependency-name: google-github-actions/get-secretmanager-secrets dependency-version: '3' 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 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 5b82ec5ac..155bd1db1 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -76,7 +76,7 @@ jobs: # roles/secretmanager.secretAccessor on these secrets. - name: Fetch feedback secrets from Secret Manager id: feedback-secrets - uses: 'google-github-actions/get-secretmanager-secrets@v2' + uses: 'google-github-actions/get-secretmanager-secrets@v3' with: secrets: |- jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 3a38dddbd..047237d9d 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -41,7 +41,7 @@ jobs: # roles/secretmanager.secretAccessor on these secrets. - name: Fetch feedback secrets from Secret Manager id: feedback-secrets - uses: 'google-github-actions/get-secretmanager-secrets@v2' + uses: 'google-github-actions/get-secretmanager-secrets@v3' with: secrets: |- jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index 6150195e9..66c96a2ce 100644 --- a/.github/workflows/CD_testing.yml +++ b/.github/workflows/CD_testing.yml @@ -41,7 +41,7 @@ jobs: # roles/secretmanager.secretAccessor on these secrets. - name: Fetch feedback secrets from Secret Manager id: feedback-secrets - uses: 'google-github-actions/get-secretmanager-secrets@v2' + uses: 'google-github-actions/get-secretmanager-secrets@v3' with: secrets: |- jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email From 5995d48c460f16eb98baf1d0fa98fd4ab3d49743 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Mon, 15 Jun 2026 16:54:57 -0400 Subject: [PATCH 046/160] Extract _build_assoc_exists helper to remove duplicated EXISTS subquery shape. _apply_thing_contacts_filter, _apply_thing_groups_filter, and _apply_contact_things_filter all defined the same inner function pattern: select(1).from(assoc).join(target).where(owner_fk, [extra], predicate). Extracted that pattern into a module-level _build_assoc_exists helper and replaced all three inner functions with calls to it. Behaviour is unchanged; the null/nnull unconditional checks pass no predicate (optional arg). --- services/query_helper.py | 96 +++++++++++++++++++++++----------------- 1 file changed, 55 insertions(+), 41 deletions(-) diff --git a/services/query_helper.py b/services/query_helper.py index aeb142273..1f75b13d2 100644 --- a/services/query_helper.py +++ b/services/query_helper.py @@ -550,6 +550,37 @@ def _apply_contact_virtual_sort( ) +def _build_assoc_exists( + assoc_table, + target_table, + assoc_join_col, + assoc_owner_col, + owner_pk, + predicate=None, + extra: list | None = None, +): + """Correlated EXISTS subquery for many-to-many association filters. + + Builds ``SELECT 1 FROM assoc JOIN target ON assoc_join_col = target.id + WHERE assoc_owner_col = owner_pk [AND extra...] [AND predicate]``. + + Shared by _apply_thing_contacts_filter, _apply_thing_groups_filter, and + _apply_contact_things_filter to avoid repeating the same subquery shape. + Omit ``predicate`` to get an unconditional existence check (null/nnull). + """ + where_clauses = [assoc_owner_col == owner_pk] + if extra: + where_clauses.extend(extra) + if predicate is not None: + where_clauses.append(predicate) + return ( + select(1) + .select_from(assoc_table) + .join(target_table, assoc_join_col == target_table.id) + .where(*where_clauses) + ) + + def _apply_thing_contacts_filter( sql: Select[Any], thing_table: type, @@ -580,22 +611,18 @@ def _apply_thing_contacts_filter( c = Contact def _linked_contact_select(predicate): - return ( - select(1) - .select_from(tca) - .join(c, tca.contact_id == c.id) - .where( - tca.thing_id == thing_table.id, - c.name.isnot(None), - predicate, - ) + return _build_assoc_exists( + tca, + c, + tca.contact_id, + tca.thing_id, + thing_table.id, + predicate, + extra=[c.name.isnot(None)], ) - any_linked_contact = ( - select(1) - .select_from(tca) - .join(c, tca.contact_id == c.id) - .where(tca.thing_id == thing_table.id) + any_linked_contact = _build_assoc_exists( + tca, c, tca.contact_id, tca.thing_id, thing_table.id ) if operator == "nnull": @@ -650,21 +677,12 @@ def _apply_thing_groups_filter( g = Group def _linked_group_select(predicate): - return ( - select(1) - .select_from(gta) - .join(g, gta.group_id == g.id) - .where( - gta.thing_id == thing_table.id, - predicate, - ) + return _build_assoc_exists( + gta, g, gta.group_id, gta.thing_id, thing_table.id, predicate ) - any_linked_group = ( - select(1) - .select_from(gta) - .join(g, gta.group_id == g.id) - .where(gta.thing_id == thing_table.id) + any_linked_group = _build_assoc_exists( + gta, g, gta.group_id, gta.thing_id, thing_table.id ) if operator == "nnull": @@ -750,22 +768,18 @@ def _apply_contact_things_filter( t = Thing def _linked_thing_select(predicate): - return ( - select(1) - .select_from(tca) - .join(t, tca.thing_id == t.id) - .where( - tca.contact_id == contact_table.id, - t.name.isnot(None), - predicate, - ) + return _build_assoc_exists( + tca, + t, + tca.thing_id, + tca.contact_id, + contact_table.id, + predicate, + extra=[t.name.isnot(None)], ) - any_linked_thing = ( - select(1) - .select_from(tca) - .join(t, tca.thing_id == t.id) - .where(tca.contact_id == contact_table.id) + any_linked_thing = _build_assoc_exists( + tca, t, tca.thing_id, tca.contact_id, contact_table.id ) if operator == "nnull": From 1bf1977ec78b8c30ee77c9c68b329e4d50716581 Mon Sep 17 00:00:00 2001 From: Peter Rowland Date: Tue, 16 Jun 2026 12:47:58 -0700 Subject: [PATCH 047/160] feat: add geothermal OGC collections and fix Docker pygeoapi config Add geothermal_wells_bht and geothermal_wells_temperature_profile to the pygeoapi-config.yml template, exposing NM_Wells geothermal views as OGC API - Features collections. Fix _resolve() priority in pygeoapi.py so that shell PYGEOAPI_* env vars (e.g. PYGEOAPI_POSTGRES_HOST=db set in docker-compose) take precedence over generic POSTGRES_* values from .env, preventing localhost from shadowing the Docker service hostname. Add INSTALL_DEV=true build arg to docker-compose so faker and other dev dependencies are present when the container runs in development mode. Co-Authored-By: Claude Sonnet 4.6 --- core/pygeoapi-config.yml | 46 ++++++++++++++++++++++++ core/pygeoapi.py | 77 +++++++++++++++++++++++----------------- docker-compose.yml | 4 ++- 3 files changed, 93 insertions(+), 34 deletions(-) diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index 1bae81d90..7464e6a08 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -288,3 +288,49 @@ resources: geom_field: project_area {thing_collections_block} + + 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] + 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_geothermal_wells_bht + geom_field: geom + + 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] + 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_geothermal_wells_temperature_profile + geom_field: geom diff --git a/core/pygeoapi.py b/core/pygeoapi.py index 7783af100..b40489f85 100644 --- a/core/pygeoapi.py +++ b/core/pygeoapi.py @@ -206,39 +206,55 @@ def _thing_collections_block( def _pygeoapi_db_settings() -> tuple[str, str, str, str, str]: - host = ( - (os.environ.get("PYGEOAPI_POSTGRES_HOST") or "").strip() - or (os.environ.get("POSTGRES_HOST") or "").strip() - or "127.0.0.1" - ) - port = ( - (os.environ.get("PYGEOAPI_POSTGRES_PORT") or "").strip() - or (os.environ.get("POSTGRES_PORT") or "").strip() - or "5432" - ) - dbname = ( - (os.environ.get("PYGEOAPI_POSTGRES_DB") or "").strip() - or (os.environ.get("POSTGRES_DB") or "").strip() - or "postgres" - ) - user = (os.environ.get("PYGEOAPI_POSTGRES_USER") or "").strip() or ( - os.environ.get("POSTGRES_USER") or "" - ).strip() + from dotenv import dotenv_values + + # Read .env directly so stale shell vars (e.g. from conda) can't override + # PYGEOAPI_POSTGRES_* values. Shell env takes precedence only for the + # PYGEOAPI_-prefixed keys (explicit per-service override), while the + # generic POSTGRES_* fallback always comes from the file. + env_file = Path(__file__).resolve().parents[1] / ".env" + dotenv = dotenv_values(env_file) if env_file.exists() else {} + + def _resolve(pygeoapi_key: str, fallback_key: str, default: str = "") -> str: + # Priority: .env PYGEOAPI_* > shell PYGEOAPI_* > .env POSTGRES_* > + # shell POSTGRES_* > hard default. + # Shell PYGEOAPI_* comes before .env POSTGRES_* so that explicit + # per-service overrides in Docker (e.g. PYGEOAPI_POSTGRES_HOST=db) + # beat the generic localhost values in .env. + return ( + (dotenv.get(pygeoapi_key) or "").strip() + or (os.environ.get(pygeoapi_key) or "").strip() + or (dotenv.get(fallback_key) or "").strip() + or (os.environ.get(fallback_key) or "").strip() + or default + ) + + host = _resolve("PYGEOAPI_POSTGRES_HOST", "POSTGRES_HOST", "127.0.0.1") + port = _resolve("PYGEOAPI_POSTGRES_PORT", "POSTGRES_PORT", "5432") + dbname = _resolve("PYGEOAPI_POSTGRES_DB", "POSTGRES_DB", "postgres") + user = _resolve("PYGEOAPI_POSTGRES_USER", "POSTGRES_USER") if not user: raise RuntimeError( "PYGEOAPI_POSTGRES_USER or POSTGRES_USER must be set and " - "non-empty to generate the pygeoapi configuration." + "non-empty in the environment or .env file." ) - if os.environ.get("PYGEOAPI_POSTGRES_PASSWORD") is None: + # Resolve the actual password at config-write time and embed it directly + # in the generated config file (which is already chmod 0600). This avoids + # stale shell env vars corrupting the ${VAR} expansion that pygeoapi's + # yaml_load would otherwise perform at request time. + password = _resolve("PYGEOAPI_POSTGRES_PASSWORD", "POSTGRES_PASSWORD") + if not password: raise RuntimeError( - "PYGEOAPI_POSTGRES_PASSWORD must be set to " - "generate the pygeoapi configuration." + "PYGEOAPI_POSTGRES_PASSWORD or POSTGRES_PASSWORD must be set " + "and non-empty in the environment or .env file." ) - return host, port, dbname, user, "${PYGEOAPI_POSTGRES_PASSWORD}" + return host, port, dbname, user, password def _write_config(path: Path) -> None: - host, port, dbname, user, password_placeholder = _pygeoapi_db_settings() + host, port, dbname, user, password = _pygeoapi_db_settings() + # Escape braces so str.format() doesn't misinterpret them in the password. + password_for_format = password.replace("{", "{{").replace("}", "}}") template = _template_path().read_text(encoding="utf-8") config = template.format( server_url=_server_url(), @@ -246,24 +262,19 @@ def _write_config(path: Path) -> None: postgres_port=port, postgres_db=dbname, postgres_user=user, - postgres_password_env=password_placeholder, + postgres_password_env=password_for_format, thing_collections_block=_thing_collections_block( host=host, port=port, dbname=dbname, user=user, - password_placeholder=password_placeholder, + password_placeholder=password, ), ) - # 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 contains database credentials + # including the plaintext password. It is protected by chmod 0600. # * Do not commit it to version control. # * Do not expose it in logs, error messages, or diagnostics. - # * Ensure filesystem permissions restrict access appropriately. path.write_text(config, encoding="utf-8") path.chmod(0o600) diff --git a/docker-compose.yml b/docker-compose.yml index 78120d761..94991fb99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,9 +23,11 @@ services: retries: 20 app: - build: + build: context: . dockerfile: ./docker/app/Dockerfile + args: + INSTALL_DEV: "true" environment: - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} From b0400d4de5c539c89e1fe35e99316314623b2256 Mon Sep 17 00:00:00 2001 From: Peter Rowland Date: Tue, 16 Jun 2026 12:51:35 -0700 Subject: [PATCH 048/160] Migrations adding migrations and views --- ...ure_profile_view_duplicate_well_data_id.py | 132 +++++++++++ ...data_id_to_text_in_geothermal_ogc_views.py | 217 ++++++++++++++++++ ..._add_integer_id_to_geothermal_ogc_views.py | 215 +++++++++++++++++ ...add_fk_constraints_to_nmw_mirror_tables.py | 210 +++++++++++++++++ 4 files changed, 774 insertions(+) create mode 100644 alembic/versions/a3b4c5d6e7f8_fix_temperature_profile_view_duplicate_well_data_id.py create mode 100644 alembic/versions/b4c5d6e7f8a9_cast_well_data_id_to_text_in_geothermal_ogc_views.py create mode 100644 alembic/versions/c5d6e7f8a9b0_add_integer_id_to_geothermal_ogc_views.py create mode 100644 alembic/versions/z2a3b4c5d6e7_add_fk_constraints_to_nmw_mirror_tables.py diff --git a/alembic/versions/a3b4c5d6e7f8_fix_temperature_profile_view_duplicate_well_data_id.py b/alembic/versions/a3b4c5d6e7f8_fix_temperature_profile_view_duplicate_well_data_id.py new file mode 100644 index 000000000..dc2cd4642 --- /dev/null +++ b/alembic/versions/a3b4c5d6e7f8_fix_temperature_profile_view_duplicate_well_data_id.py @@ -0,0 +1,132 @@ +"""fix temperature profile view duplicate well_data_id + +Revision ID: a3b4c5d6e7f8 +Revises: z2a3b4c5d6e7 +Create Date: 2026-06-15 + +NMW_WellLocations has multiple rows per WellDataID (OBJECTID is its PK, not +WellDataID). The prior view grouped by WellDataID + Lat_dd83 + Long_dd83, +producing one row per (well, location) pair. When a well has more than one +location row the unique index on well_data_id fails at REFRESH time. + +Fix: deduplicate NMW_WellLocations to one row per WellDataID via DISTINCT ON +before joining, so the GROUP BY always yields exactly one row per well. +""" + +from alembic import op +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision = "a3b4c5d6e7f8" +down_revision = "z2a3b4c5d6e7" +branch_labels = None +depends_on = None + +_VIEW = "ogc_geothermal_wells_temperature_profile" + + +def upgrade() -> None: + op.execute(text(f'DROP MATERIALIZED VIEW IF EXISTS "{_VIEW}"')) + op.execute( + text( + f""" + CREATE MATERIALIZED VIEW "{_VIEW}" AS + 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" + ) + SELECT + r."WellDataID" 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_{_VIEW}_well_data_id " + f'ON "{_VIEW}" (well_data_id)' + ) + ) + op.execute(text(f'CREATE INDEX ix_{_VIEW}_geom ON "{_VIEW}" USING GIST (geom)')) + + +def downgrade() -> None: + op.execute(text(f'DROP MATERIALIZED VIEW IF EXISTS "{_VIEW}"')) + # Restore the original view (without the DISTINCT ON deduplication). + op.execute( + text( + f""" + CREATE MATERIALIZED VIEW "{_VIEW}" AS + SELECT + r."WellDataID" 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 "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + WHERE loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL + AND 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_{_VIEW}_well_data_id " + f'ON "{_VIEW}" (well_data_id)' + ) + ) + op.execute(text(f'CREATE INDEX ix_{_VIEW}_geom ON "{_VIEW}" USING GIST (geom)')) diff --git a/alembic/versions/b4c5d6e7f8a9_cast_well_data_id_to_text_in_geothermal_ogc_views.py b/alembic/versions/b4c5d6e7f8a9_cast_well_data_id_to_text_in_geothermal_ogc_views.py new file mode 100644 index 000000000..1f68a4fd2 --- /dev/null +++ b/alembic/versions/b4c5d6e7f8a9_cast_well_data_id_to_text_in_geothermal_ogc_views.py @@ -0,0 +1,217 @@ +"""cast well_data_id to text in geothermal OGC views + +Revision ID: b4c5d6e7f8a9 +Revises: a3b4c5d6e7f8 +Create Date: 2026-06-15 + +pygeoapi does not support UUID id_field columns. Cast well_data_id to text in +both geothermal OGC views so pygeoapi can use it as the feature identifier. +""" + +from alembic import op +from sqlalchemy import text + +revision = "b4c5d6e7f8a9" +down_revision = "a3b4c5d6e7f8" +branch_labels = None +depends_on = None + +_BHT_VIEW = "ogc_geothermal_wells_bht" +_PROFILE_VIEW = "ogc_geothermal_wells_temperature_profile" + + +def upgrade() -> None: + # BHT view — plain view, just DROP and recreate + op.execute(text(f'DROP VIEW IF EXISTS "{_BHT_VIEW}"')) + op.execute( + text( + f""" + CREATE VIEW "{_BHT_VIEW}" AS + SELECT + 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 "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + WHERE loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL + GROUP BY + r."WellDataID", + loc."Lat_dd83", + loc."Long_dd83", + hdr."CurWellNam", + hdr."API", + hdr."TotalDepth" + """ + ) + ) + + # Temperature profile — materialized view, DROP indexes first + op.execute(text(f'DROP MATERIALIZED VIEW IF EXISTS "{_PROFILE_VIEW}"')) + op.execute( + text( + f""" + CREATE MATERIALIZED VIEW "{_PROFILE_VIEW}" AS + 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" + ) + SELECT + 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}_well_data_id " + f'ON "{_PROFILE_VIEW}" (well_data_id)' + ) + ) + op.execute( + text( + f'CREATE INDEX ix_{_PROFILE_VIEW}_geom ON "{_PROFILE_VIEW}" USING GIST (geom)' + ) + ) + + +def downgrade() -> None: + # Restore UUID (non-text) versions + op.execute(text(f'DROP VIEW IF EXISTS "{_BHT_VIEW}"')) + op.execute( + text( + f""" + CREATE VIEW "{_BHT_VIEW}" AS + SELECT + r."WellDataID" 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 "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + WHERE loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL + 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 + 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" + ) + SELECT + r."WellDataID" 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}_well_data_id " + f'ON "{_PROFILE_VIEW}" (well_data_id)' + ) + ) + op.execute( + text( + f'CREATE INDEX ix_{_PROFILE_VIEW}_geom ON "{_PROFILE_VIEW}" USING GIST (geom)' + ) + ) diff --git a/alembic/versions/c5d6e7f8a9b0_add_integer_id_to_geothermal_ogc_views.py b/alembic/versions/c5d6e7f8a9b0_add_integer_id_to_geothermal_ogc_views.py new file mode 100644 index 000000000..ca1bdf2b8 --- /dev/null +++ b/alembic/versions/c5d6e7f8a9b0_add_integer_id_to_geothermal_ogc_views.py @@ -0,0 +1,215 @@ +"""add integer id to geothermal OGC views + +Revision ID: c5d6e7f8a9b0 +Revises: b4c5d6e7f8a9 +Create Date: 2026-06-15 + +All other OGC views use an integer id_field (from thing.id). pygeoapi's +PostgreSQL provider is tested against integer PKs. Replace well_data_id as the +id_field with row_number() OVER () AS id to match the convention, and keep +well_data_id as a regular attribute column. +""" + +from alembic import op +from sqlalchemy import text + +revision = "c5d6e7f8a9b0" +down_revision = "b4c5d6e7f8a9" +branch_labels = None +depends_on = None + +_BHT_VIEW = "ogc_geothermal_wells_bht" +_PROFILE_VIEW = "ogc_geothermal_wells_temperature_profile" + + +def upgrade() -> None: + op.execute(text(f'DROP VIEW IF EXISTS "{_BHT_VIEW}"')) + op.execute( + text( + f""" + CREATE VIEW "{_BHT_VIEW}" AS + 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 "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + WHERE loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL + 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 + 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" + ) + 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 " f'ON "{_PROFILE_VIEW}" (id)') + ) + op.execute( + text( + f'CREATE INDEX ix_{_PROFILE_VIEW}_geom ON "{_PROFILE_VIEW}" USING GIST (geom)' + ) + ) + + +def downgrade() -> None: + op.execute(text(f'DROP VIEW IF EXISTS "{_BHT_VIEW}"')) + op.execute( + text( + f""" + CREATE VIEW "{_BHT_VIEW}" AS + SELECT + 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 "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + WHERE loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL + 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 + 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" + ) + SELECT + 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}_well_data_id " + f'ON "{_PROFILE_VIEW}" (well_data_id)' + ) + ) + op.execute( + text( + f'CREATE INDEX ix_{_PROFILE_VIEW}_geom ON "{_PROFILE_VIEW}" USING GIST (geom)' + ) + ) diff --git a/alembic/versions/z2a3b4c5d6e7_add_fk_constraints_to_nmw_mirror_tables.py b/alembic/versions/z2a3b4c5d6e7_add_fk_constraints_to_nmw_mirror_tables.py new file mode 100644 index 000000000..b42dfd5a4 --- /dev/null +++ b/alembic/versions/z2a3b4c5d6e7_add_fk_constraints_to_nmw_mirror_tables.py @@ -0,0 +1,210 @@ +"""add FK constraints to NMW staging mirror tables + +Revision ID: z2a3b4c5d6e7 +Revises: y1z2a3b4c5d6 +Create Date: 2026-06-15 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "z2a3b4c5d6e7" +down_revision = "y1z2a3b4c5d6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # WellLocations -> WellHeaders + op.create_foreign_key( + "fk_nmw_welllocations_welldataid", + "NMW_WellLocations", + "NMW_WellHeaders", + ["WellDataID"], + ["WellDataID"], + ) + # WellRecords -> WellHeaders + op.create_foreign_key( + "fk_nmw_wellrecords_welldataid", + "NMW_WellRecords", + "NMW_WellHeaders", + ["WellDataID"], + ["WellDataID"], + ) + # WellZDatum -> WellRecords + op.create_foreign_key( + "fk_nmw_wellzdatum_recrdsetid", + "NMW_WellZDatum", + "NMW_WellRecords", + ["RecrdsetID"], + ["RecrdSetID"], + ) + # WellSamples -> WellRecords + op.create_foreign_key( + "fk_nmw_wellsamples_recrdsetid", + "NMW_WellSamples", + "NMW_WellRecords", + ["RecrdsetID"], + ["RecrdSetID"], + ) + # GtBhtHeaders -> WellSamples + op.create_foreign_key( + "fk_nmw_gtbhtheaders_samplsetid", + "NMW_GtBhtHeaders", + "NMW_WellSamples", + ["SamplSetID"], + ["SamplSetID"], + ) + # GtBhtData -> GtBhtHeaders + op.create_foreign_key( + "fk_nmw_gtbhtdata_bhtguid", + "NMW_GtBhtData", + "NMW_GtBhtHeaders", + ["BHTGUID"], + ["BHTGUID"], + ) + # WsIntervals -> WellSamples + op.create_foreign_key( + "fk_nmw_wsintervals_samplsetid", + "NMW_WsIntervals", + "NMW_WellSamples", + ["SamplSetID"], + ["SamplSetID"], + ) + # GtConductivity -> WsIntervals + op.create_foreign_key( + "fk_nmw_gtconductivity_intrvlguid", + "NMW_GtConductivity", + "NMW_WsIntervals", + ["IntrvlGUID"], + ["IntrvlGUID"], + ) + # GtHeatFlow -> WsIntervals + op.create_foreign_key( + "fk_nmw_gtheatflow_intrvlguid", + "NMW_GtHeatFlow", + "NMW_WsIntervals", + ["IntrvlGUID"], + ["IntrvlGUID"], + ) + # GtSumHeatFlow -> WellRecords + op.create_foreign_key( + "fk_nmw_gtsumheatflow_recrdsetid", + "NMW_GtSumHeatFlow", + "NMW_WellRecords", + ["RecrdSetID"], + ["RecrdSetID"], + ) + # GtSumHeatFlow -> WellSamples + op.create_foreign_key( + "fk_nmw_gtsumheatflow_samplsetid", + "NMW_GtSumHeatFlow", + "NMW_WellSamples", + ["SamplSetID"], + ["SamplSetID"], + ) + # GtTempDepths -> WellSamples + op.create_foreign_key( + "fk_nmw_gttempdepths_samplsetid", + "NMW_GtTempDepths", + "NMW_WellSamples", + ["SamplSetID"], + ["SamplSetID"], + ) + # WsDstHeaders -> WellSamples + op.create_foreign_key( + "fk_nmw_wsdstheaders_samplsetid", + "NMW_WsDstHeaders", + "NMW_WellSamples", + ["SamplSetID"], + ["SamplSetID"], + ) + # WsDstIntervals -> WsDstHeaders + op.create_foreign_key( + "fk_nmw_wsdstintervals_dstguid", + "NMW_WsDstIntervals", + "NMW_WsDstHeaders", + ["DSTGUID"], + ["DSTGUID"], + ) + # WsDstFlowHistory -> WsDstIntervals + op.create_foreign_key( + "fk_nmw_wsdstflowhistory_dstinterval", + "NMW_WsDstFlowHistory", + "NMW_WsDstIntervals", + ["DSTInterval"], + ["DSTInterval"], + ) + # WsDstFluidProperties -> WsDstIntervals + op.create_foreign_key( + "fk_nmw_wsdstfluidproperties_dstinterval", + "NMW_WsDstFluidProperties", + "NMW_WsDstIntervals", + ["DSTInterval"], + ["DSTInterval"], + ) + # WsDstPressure -> WsDstIntervals + op.create_foreign_key( + "fk_nmw_wsdstpressure_dstinterval", + "NMW_WsDstPressure", + "NMW_WsDstIntervals", + ["DSTInterval"], + ["DSTInterval"], + ) + + +def downgrade() -> None: + op.drop_constraint( + "fk_nmw_wsdstpressure_dstinterval", "NMW_WsDstPressure", type_="foreignkey" + ) + op.drop_constraint( + "fk_nmw_wsdstfluidproperties_dstinterval", + "NMW_WsDstFluidProperties", + type_="foreignkey", + ) + op.drop_constraint( + "fk_nmw_wsdstflowhistory_dstinterval", + "NMW_WsDstFlowHistory", + type_="foreignkey", + ) + op.drop_constraint( + "fk_nmw_wsdstintervals_dstguid", "NMW_WsDstIntervals", type_="foreignkey" + ) + op.drop_constraint( + "fk_nmw_wsdstheaders_samplsetid", "NMW_WsDstHeaders", type_="foreignkey" + ) + op.drop_constraint( + "fk_nmw_gttempdepths_samplsetid", "NMW_GtTempDepths", type_="foreignkey" + ) + op.drop_constraint( + "fk_nmw_gtsumheatflow_samplsetid", "NMW_GtSumHeatFlow", type_="foreignkey" + ) + op.drop_constraint( + "fk_nmw_gtsumheatflow_recrdsetid", "NMW_GtSumHeatFlow", type_="foreignkey" + ) + op.drop_constraint( + "fk_nmw_gtheatflow_intrvlguid", "NMW_GtHeatFlow", type_="foreignkey" + ) + op.drop_constraint( + "fk_nmw_gtconductivity_intrvlguid", "NMW_GtConductivity", type_="foreignkey" + ) + op.drop_constraint( + "fk_nmw_wsintervals_samplsetid", "NMW_WsIntervals", type_="foreignkey" + ) + op.drop_constraint("fk_nmw_gtbhtdata_bhtguid", "NMW_GtBhtData", type_="foreignkey") + op.drop_constraint( + "fk_nmw_gtbhtheaders_samplsetid", "NMW_GtBhtHeaders", type_="foreignkey" + ) + op.drop_constraint( + "fk_nmw_wellsamples_recrdsetid", "NMW_WellSamples", type_="foreignkey" + ) + op.drop_constraint( + "fk_nmw_wellzdatum_recrdsetid", "NMW_WellZDatum", type_="foreignkey" + ) + op.drop_constraint( + "fk_nmw_wellrecords_welldataid", "NMW_WellRecords", type_="foreignkey" + ) + op.drop_constraint( + "fk_nmw_welllocations_welldataid", "NMW_WellLocations", type_="foreignkey" + ) From 6f6f1219962bafd4f565048e7827988a3c75203a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:27:29 +0000 Subject: [PATCH 049/160] build(deps): bump python-multipart from 0.0.27 to 0.0.31 (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.27 to 0.0.31.
Release notes

Sourced from python-multipart's releases.

Version 0.0.31

What's Changed

Full Changelog: https://github.com/Kludex/python-multipart/compare/0.0.30...0.0.31

Version 0.0.30

What's Changed

Full Changelog: https://github.com/Kludex/python-multipart/compare/0.0.29...0.0.30

Version 0.0.29

What's Changed

Full Changelog: https://github.com/Kludex/python-multipart/compare/0.0.28...0.0.29

Version 0.0.28

What's Changed

Full Changelog: https://github.com/Kludex/python-multipart/compare/0.0.27...0.0.28

Changelog

Sourced from python-multipart's changelog.

0.0.31 (2026-06-04)

  • Speed up multipart header parsing and callback dispatch #295.
  • Bound header field name size before validating #296.
  • Validate Content-Length is non-negative in parse_form #297.

0.0.30 (2026-05-31)

  • Parse application/x-www-form-urlencoded bodies per the WHATWG URL standard, treating only & as a field separator #290.
  • Ignore RFC 2231/5987 extended parameters (name*, filename*) in parse_options_header, keeping the plain parameter authoritative per RFC 7578 §4.2 #291.

0.0.29 (2026-05-17)

  • Handle malformed RFC 2231 continuations in parse_options_header #270.

0.0.28 (2026-05-10)

  • Speed up partial-boundary tail scan via bytes.find #281.
  • Cap multipart boundary length at 256 bytes #282.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=python-multipart&package-manager=uv&previous-version=0.0.27&new-version=0.0.31)](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 632b2c332..ad54b7c12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dependencies = [ "pyshp==2.3.1", "python-dateutil==2.9.0.post0", "python-jose>=3.5.0", - "python-multipart==0.0.27", + "python-multipart==0.0.31", "pytz==2025.2", "requests==2.34.2", "rsa==4.9.1", diff --git a/requirements.txt b/requirements.txt index 21e316804..2bfa37b5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1319,9 +1319,9 @@ python-jose==3.5.0 \ --hash=sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771 \ --hash=sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b # via ocotilloapi -python-multipart==0.0.27 \ - --hash=sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645 \ - --hash=sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602 +python-multipart==0.0.31 \ + --hash=sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28 \ + --hash=sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680 # via # ocotilloapi # starlette-admin diff --git a/uv.lock b/uv.lock index cf36f80f2..9c845d2fc 100644 --- a/uv.lock +++ b/uv.lock @@ -1677,7 +1677,7 @@ requires-dist = [ { name = "pyshp", specifier = "==2.3.1" }, { name = "python-dateutil", specifier = "==2.9.0.post0" }, { name = "python-jose", specifier = ">=3.5.0" }, - { name = "python-multipart", specifier = "==0.0.27" }, + { name = "python-multipart", specifier = "==0.0.31" }, { name = "pytz", specifier = "==2025.2" }, { name = "requests", specifier = "==2.34.2" }, { name = "rsa", specifier = "==4.9.1" }, @@ -2443,11 +2443,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.27" +version = "0.0.31" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" }, ] [[package]] From b2b13c5c430bf4799a28d52f1e352b3647befb08 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:29:52 +0000 Subject: [PATCH 050/160] build(deps): bump starlette from 1.0.1 to 1.3.1 (#725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [starlette](https://github.com/Kludex/starlette) from 1.0.1 to 1.3.1.
Release notes

Sourced from starlette's releases.

Version 1.3.1

What's Changed

Full Changelog: https://github.com/Kludex/starlette/compare/1.3.0...1.3.1

Version 1.3.0

What's Changed

New Contributors

Full Changelog: https://github.com/Kludex/starlette/compare/1.2.1...1.3.0

Version 1.2.1

What's Changed

New Contributors

Full Changelog: https://github.com/Kludex/starlette/compare/1.2.0...1.2.1

Version 1.2.0

What's Changed

Full Changelog: https://github.com/Kludex/starlette/compare/1.1.0...1.2.0

Version 1.1.0

... (truncated)

Changelog

Sourced from starlette's changelog.

1.3.1 (June 12, 2026)

Fixed

  • Enforce max_fields and max_part_size in FormParser #3329.
  • Enforce FormParser limits in parser callbacks #3331.

1.3.0 (June 11, 2026)

Added

  • Add httpx2 to the full extra #3323.
  • Annotate the URLPath protocol parameter with Literal #3285.

Fixed

  • Build request.url from structured components #3326.
  • Clamp oversized suffix ranges in FileResponse #3307.
  • Catch OSError alongside MultiPartException when closing temp files #3191.
  • Avoid collapsing exception groups raised from user code #2830.
  • Use removeprefix to strip the weak ETag indicator in is_not_modified #3193.
  • Fix IndexError in URL.replace() on a URL with no authority #3317.
  • Adjust testclient typing and warnings #3322.

1.2.1 (May 31, 2026)

Fixed

  • Use httpx2 for type checking in the testclient module #3304.
  • Add assert error for requires() when the request parameter is not a Request type #3298.

1.2.0 (May 28, 2026)

Added

  • Support httpx2 in the test client #3291.

1.1.0 (May 23, 2026)

Added

  • Use "application/octet-stream" as the FileResponse media type fallback #3283.

Fixed

  • Only dispatch standard HTTP verbs in HTTPEndpoint #3286.
  • Reject absolute paths in StaticFiles.lookup_path #3287.
Commits
  • 8ebffd0 Version 1.3.1 (#3330)
  • 25b8e17 Enforce FormParser limits in parser callbacks (#3331)
  • dba1c4b Enforce max_fields and max_part_size in FormParser (#3329)
  • 45e51dc Use StarletteDeprecationWarning instead of DeprecationWarning (#3119)
  • 5f8610c Version 1.3.0 (#3327)
  • 167b585 Build request.url from structured components (#3326)
  • 3730925 Use removeprefix to strip weak ETag indicator in is_not_modified (#3193)
  • e6f7ad1 avoid collapsing exception groups from user code (#2830)
  • 115228f Annotate URLPath protocol parameter with Literal (#3285)
  • 113f193 docs: replace inline ASGI server list with link to canonical implemen… (#3204)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=starlette&package-manager=uv&previous-version=1.0.1&new-version=1.3.1)](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 ad54b7c12..7439beaee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,7 +90,7 @@ dependencies = [ "sqlalchemy-continuum==1.6.0", "sqlalchemy-searchable==2.1.0", "sqlalchemy-utils==0.42.1", - "starlette==1.0.1", + "starlette==1.3.1", "starlette-admin[i18n]==0.16.1", "typer==0.26.7", "typing-extensions==4.15.0", diff --git a/requirements.txt b/requirements.txt index 2bfa37b5d..2a7a7b738 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1635,9 +1635,9 @@ sqlalchemy-utils==0.42.1 \ # via # ocotilloapi # sqlalchemy-searchable -starlette==1.0.1 \ - --hash=sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f \ - --hash=sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd +starlette==1.3.1 \ + --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \ + --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6 # via # apitally # fastapi diff --git a/uv.lock b/uv.lock index 9c845d2fc..bafedc9d2 100644 --- a/uv.lock +++ b/uv.lock @@ -1690,7 +1690,7 @@ requires-dist = [ { name = "sqlalchemy-continuum", specifier = "==1.6.0" }, { name = "sqlalchemy-searchable", specifier = "==2.1.0" }, { name = "sqlalchemy-utils", specifier = "==0.42.1" }, - { name = "starlette", specifier = "==1.0.1" }, + { name = "starlette", specifier = "==1.3.1" }, { name = "starlette-admin", extras = ["i18n"], specifier = "==0.16.1" }, { name = "typer", specifier = "==0.26.7" }, { name = "typing-extensions", specifier = "==4.15.0" }, @@ -2905,14 +2905,14 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.1" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, + { 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]] From 24e39d87e01254e5765a24e7f92bbcfa00a26cc3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:29:55 +0000 Subject: [PATCH 051/160] build(deps): bump pytz from 2025.2 to 2026.2 Bumps [pytz](https://github.com/stub42/pytz) from 2025.2 to 2026.2. - [Release notes](https://github.com/stub42/pytz/releases) - [Commits](https://github.com/stub42/pytz/compare/release_2025.2...release_2026.2) --- updated-dependencies: - dependency-name: pytz dependency-version: '2026.2' 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 ad54b7c12..01b9225b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ dependencies = [ "python-dateutil==2.9.0.post0", "python-jose>=3.5.0", "python-multipart==0.0.31", - "pytz==2025.2", + "pytz==2026.2", "requests==2.34.2", "rsa==4.9.1", "scramp==1.4.8", diff --git a/requirements.txt b/requirements.txt index 2bfa37b5d..ec0e7ad4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1325,9 +1325,9 @@ python-multipart==0.0.31 \ # via # ocotilloapi # starlette-admin -pytz==2025.2 \ - --hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \ - --hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00 +pytz==2026.2 \ + --hash=sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126 \ + --hash=sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a # via # dateparser # ocotilloapi diff --git a/uv.lock b/uv.lock index 9c845d2fc..ee059ef62 100644 --- a/uv.lock +++ b/uv.lock @@ -1678,7 +1678,7 @@ requires-dist = [ { name = "python-dateutil", specifier = "==2.9.0.post0" }, { name = "python-jose", specifier = ">=3.5.0" }, { name = "python-multipart", specifier = "==0.0.31" }, - { name = "pytz", specifier = "==2025.2" }, + { name = "pytz", specifier = "==2026.2" }, { name = "requests", specifier = "==2.34.2" }, { name = "rsa", specifier = "==4.9.1" }, { name = "scramp", specifier = "==1.4.8" }, @@ -2476,11 +2476,11 @@ wheels = [ [[package]] name = "pytz" -version = "2025.2" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, + { 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" }, ] [[package]] From c1701d31ca5684500bf3a906c74fbb1d7b9422c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:43:48 +0000 Subject: [PATCH 052/160] build(deps): bump cryptography from 46.0.7 to 48.0.1 Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.7 to 48.0.1. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/46.0.7...48.0.1) --- updated-dependencies: - dependency-name: cryptography dependency-version: 48.0.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- requirements.txt | 94 +++++++++++++++++++++++++----------------------- uv.lock | 92 +++++++++++++++++++++++------------------------ 3 files changed, 97 insertions(+), 91 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7439beaee..34ac40564 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "charset-normalizer==3.4.7", "click==8.4.1", "cloud-sql-python-connector==1.20.3", - "cryptography==46.0.7", + "cryptography==48.0.1", "dnspython==2.8.0", "dotenv==0.9.9", "email-validator==2.3.0", diff --git a/requirements.txt b/requirements.txt index 2a7a7b738..5aad24e79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -364,50 +364,56 @@ colorama==0.4.6 ; sys_platform == 'win32' \ # via # click # typer -cryptography==46.0.7 \ - --hash=sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832 \ - --hash=sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067 \ - --hash=sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de \ - --hash=sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0 \ - --hash=sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b \ - --hash=sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef \ - --hash=sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b \ - --hash=sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4 \ - --hash=sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3 \ - --hash=sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308 \ - --hash=sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e \ - --hash=sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163 \ - --hash=sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f \ - --hash=sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee \ - --hash=sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77 \ - --hash=sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85 \ - --hash=sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99 \ - --hash=sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7 \ - --hash=sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83 \ - --hash=sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85 \ - --hash=sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006 \ - --hash=sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb \ - --hash=sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e \ - --hash=sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba \ - --hash=sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325 \ - --hash=sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d \ - --hash=sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1 \ - --hash=sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1 \ - --hash=sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2 \ - --hash=sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0 \ - --hash=sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842 \ - --hash=sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457 \ - --hash=sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2 \ - --hash=sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c \ - --hash=sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb \ - --hash=sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5 \ - --hash=sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4 \ - --hash=sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902 \ - --hash=sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246 \ - --hash=sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022 \ - --hash=sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e \ - --hash=sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298 \ - --hash=sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce +cryptography==48.0.1 \ + --hash=sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a \ + --hash=sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02 \ + --hash=sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471 \ + --hash=sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f \ + --hash=sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa \ + --hash=sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a \ + --hash=sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1 \ + --hash=sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225 \ + --hash=sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92 \ + --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:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a \ + --hash=sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429 \ + --hash=sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1 \ + --hash=sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd \ + --hash=sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9 \ + --hash=sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265 \ + --hash=sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a \ + --hash=sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475 \ + --hash=sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d \ + --hash=sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3 \ + --hash=sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c \ + --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 # via # authlib # cloud-sql-python-connector diff --git a/uv.lock b/uv.lock index bafedc9d2..42b13a046 100644 --- a/uv.lock +++ b/uv.lock @@ -623,55 +623,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.7" +version = "48.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, - { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, - { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, - { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, - { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, - { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, +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" }, ] [[package]] @@ -1626,7 +1626,7 @@ requires-dist = [ { name = "charset-normalizer", specifier = "==3.4.7" }, { name = "click", specifier = "==8.4.1" }, { name = "cloud-sql-python-connector", specifier = "==1.20.3" }, - { name = "cryptography", specifier = "==46.0.7" }, + { 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" }, From 99a84e5249e64d00abbdcf7330f9e36c2409e7dc Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 17 Jun 2026 10:20:51 -0600 Subject: [PATCH 053/160] feat(db): nightly pg_cron refresh of pygeoapi materialized views Register a pg_cron job that refreshes the pygeoapi materialized views once a night in production, with the schedule traceable in version control via an alembic migration. - alembic migration x2y3z4a5b6c7 creates the pg_cron extension, a public.refresh_pygeoapi_materialized_views() helper, and a nightly cron job (0 9 * * *, server timezone). Idempotent: it unschedules any same-named job before re-registering. - pg_cron is production-only. The migration is a no-op unless ENABLE_PG_CRON is truthy, so alembic upgrade head still works on the dev/test/CI Postgres image (which does not preload pg_cron). - services/materialized_views.py is the single source of truth for the view list, shared by the CLI refresh command and the migration. - docker/db/Dockerfile (production image) installs pg_cron and preloads it with cron.database_name pointed at the app database. - CD_staging.yml and CD_production.yml set ENABLE_PG_CRON=1 on the migration step. - docs/pg_cron-nightly-refresh.md documents setup (self-hosted Docker and Cloud SQL), verification, and the non-concurrent REFRESH rationale. Co-Authored-By: Claude Fable 5 --- .env.example | 7 + .github/workflows/CD_production.yml | 5 + .github/workflows/CD_staging.yml | 5 + ...chedule_nightly_matview_refresh_pg_cron.py | 144 ++++++++++++++++++ cli/cli.py | 13 +- docker/db/Dockerfile | 28 ++++ docs/pg_cron-nightly-refresh.md | 100 ++++++++++++ services/materialized_views.py | 17 +++ 8 files changed, 308 insertions(+), 11 deletions(-) create mode 100644 alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py create mode 100644 docs/pg_cron-nightly-refresh.md create mode 100644 services/materialized_views.py diff --git a/.env.example b/.env.example index 27f624d4c..1645fa31c 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,13 @@ GOOGLE_APPLICATION_CREDENTIALS=/path/to/gcs_credentials.json # set to development for lexicon and parameter to be populated and enable the enums to work MODE=development +# pg_cron nightly materialized-view refresh (PRODUCTION ONLY). +# Leave unset/0 in development, test, and CI: the dev Postgres image does not +# load pg_cron, and alembic migration x2y3z4a5b6c7 is a no-op when this is off. +# Set to 1 in production (DB server has shared_preload_libraries=pg_cron) to +# register the nightly refresh job. See docs/pg_cron-nightly-refresh.md. +# ENABLE_PG_CRON=0 + # disable authentication (for development only) AUTHENTIK_DISABLE_AUTHENTICATION=1 diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 155bd1db1..1160c140c 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -90,6 +90,11 @@ jobs: CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" CLOUD_SQL_IAM_AUTH: true + # Register the nightly pg_cron materialized-view refresh job. + # Requires the Cloud SQL instance flag cloudsql.enable_pg_cron=on and + # cron.database_name set to CLOUD_SQL_DATABASE. See + # docs/pg_cron-nightly-refresh.md. + ENABLE_PG_CRON: "1" run: | uv run --no-dev alembic upgrade head diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 047237d9d..0ec5baed0 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -55,6 +55,11 @@ jobs: CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" CLOUD_SQL_IAM_AUTH: true + # Register the nightly pg_cron materialized-view refresh job. + # Requires the Cloud SQL instance flag cloudsql.enable_pg_cron=on and + # cron.database_name set to CLOUD_SQL_DATABASE. See + # docs/pg_cron-nightly-refresh.md. + ENABLE_PG_CRON: "1" run: | uv run --no-dev alembic upgrade head diff --git a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py new file mode 100644 index 000000000..e85428869 --- /dev/null +++ b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py @@ -0,0 +1,144 @@ +"""schedule nightly materialized-view refresh via pg_cron + +Registers a pg_cron job that refreshes the pygeoapi materialized views +once a night. The job is created through a SQL helper function, +``public.refresh_pygeoapi_materialized_views()``, so the list of views and +the refresh logic live in the database and version control together. + +pg_cron is a *production-only* dependency. It requires the extension to be +loaded via ``shared_preload_libraries`` on the database server, which the +development docker-compose Postgres image does not do. To avoid breaking +``alembic upgrade head`` in development (and in test/CI), this migration is a +no-op unless ``ENABLE_PG_CRON`` is truthy in the environment. Production sets +``ENABLE_PG_CRON=1``; everywhere else the migration records itself as applied +without touching pg_cron. See ``docs/pg_cron-nightly-refresh.md``. + +Revision ID: x2y3z4a5b6c7 +Revises: w1x2y3z4a5b6 +Create Date: 2026-06-17 00:00:00.000000 +""" + +import re +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import text + +from services.env import get_bool_env +from services.materialized_views import PYGEOAPI_MATERIALIZED_VIEWS + +# revision identifiers, used by Alembic. +revision: str = "x2y3z4a5b6c7" +down_revision: Union[str, Sequence[str], None] = "w1x2y3z4a5b6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Name of the pg_cron job. Used to (re)register and to unschedule. +CRON_JOB_NAME = "refresh-pygeoapi-materialized-views" + +# Nightly schedule in standard cron syntax. pg_cron interprets this in the +# database server's timezone (UTC on Cloud SQL / the docker image), so 09:00 +# UTC is roughly 02:00-03:00 in US Mountain time -- comfortably off-peak. +CRON_SCHEDULE = "0 9 * * *" + + +def _build_refresh_function_sql() -> str: + """Build the helper function body from the shared view list. + + The view set is owned by ``services.materialized_views`` (the single source + of truth shared with the CLI). Plain (non-concurrent) REFRESH is used + deliberately: REFRESH ... CONCURRENTLY cannot run inside the implicit + transaction of a PL/pgSQL function, and the nightly window tolerates the + brief exclusive lock. Each view is guarded by an existence check so a + missing view never aborts the whole run. + """ + for name in PYGEOAPI_MATERIALIZED_VIEWS: + # These names are baked into a SQL literal array below; validate them + # rather than trust the constant blindly. + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): + raise ValueError(f"Invalid materialized view name: {name!r}") + + array_literal = ",\n ".join(f"'{name}'" for name in PYGEOAPI_MATERIALIZED_VIEWS) + return f""" +CREATE OR REPLACE FUNCTION public.refresh_pygeoapi_materialized_views() +RETURNS void +LANGUAGE plpgsql +AS $func$ +DECLARE + v text; + views text[] := ARRAY[ + {array_literal} + ]; +BEGIN + FOREACH v IN ARRAY views LOOP + IF EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = v) THEN + EXECUTE format('REFRESH MATERIALIZED VIEW %I', v); + END IF; + END LOOP; +END; +$func$; +""" + + +def _pg_cron_enabled() -> bool: + """pg_cron is only wired up where the server explicitly enables it.""" + return get_bool_env("ENABLE_PG_CRON", False) is True + + +def upgrade() -> None: + if not _pg_cron_enabled(): + print( + "ENABLE_PG_CRON is not set; skipping pg_cron job registration " + "(expected in development, test, and CI)." + ) + return + + bind = op.get_bind() + + # Requires shared_preload_libraries to include 'pg_cron' and the extension + # to be creatable in this database (cron.database_name = this DB). See docs. + op.execute(text("CREATE EXTENSION IF NOT EXISTS pg_cron")) + + # (Re)create the refresh helper. + op.execute(text(_build_refresh_function_sql())) + + # Drop any previously registered job with the same name so re-running this + # migration (or a re-deploy) does not accumulate duplicate schedules. + op.execute( + text( + "SELECT cron.unschedule(jobid) FROM cron.job " + "WHERE jobname = :name" + ).bindparams(name=CRON_JOB_NAME) + ) + + bind.execute( + text("SELECT cron.schedule(:name, :sched, :cmd)").bindparams( + name=CRON_JOB_NAME, + sched=CRON_SCHEDULE, + cmd="SELECT public.refresh_pygeoapi_materialized_views();", + ) + ) + + print( + f"Registered pg_cron job '{CRON_JOB_NAME}' " + f"(schedule '{CRON_SCHEDULE}', server timezone)." + ) + + +def downgrade() -> None: + if not _pg_cron_enabled(): + print("ENABLE_PG_CRON is not set; nothing to unschedule.") + return + + op.execute( + text( + "SELECT cron.unschedule(jobid) FROM cron.job " + "WHERE jobname = :name" + ).bindparams(name=CRON_JOB_NAME) + ) + op.execute( + text("DROP FUNCTION IF EXISTS public.refresh_pygeoapi_materialized_views()") + ) + # The pg_cron extension itself is left installed: it is a server-level + # capability that other jobs may depend on, and dropping it is not the + # inverse of "schedule a job". diff --git a/cli/cli.py b/cli/cli.py index 30c9742f6..f68857a5a 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -24,6 +24,8 @@ import typer from dotenv import load_dotenv +from services.materialized_views import PYGEOAPI_MATERIALIZED_VIEWS + # CLI should load `.env` defaults without clobbering an explicitly prepared environment. load_dotenv(override=False) os.environ.setdefault("OCO_LOG_CONTEXT", "cli") @@ -50,17 +52,6 @@ class SmokePopulation(str, Enum): agreed = "agreed" -PYGEOAPI_MATERIALIZED_VIEWS = ( - "ogc_latest_depth_to_water_wells", - "ogc_water_elevation_wells", - "ogc_avg_tds_wells", - "ogc_depth_to_water_trend_wells", - "ogc_water_well_summary", - "ogc_major_chemistry_results", - "ogc_minor_chemistry_wells", -) - - def _resolve_theme(theme: ThemeMode) -> ThemeMode: if theme != ThemeMode.auto: return theme diff --git a/docker/db/Dockerfile b/docker/db/Dockerfile index 4a1fbd51b..e250b7e3c 100644 --- a/docker/db/Dockerfile +++ b/docker/db/Dockerfile @@ -1 +1,29 @@ +# Production database image: PostGIS + pg_cron. +# +# This image is intentionally NOT used by the development docker-compose +# service (which runs the stock postgis/postgis image). pg_cron is a +# production-only dependency required by the nightly materialized-view refresh +# job registered in alembic migration x2y3z4a5b6c7. +# +# pg_cron must be loaded via shared_preload_libraries, and its background +# worker schedules jobs in a single database (cron.database_name). Both are set +# below so the alembic-registered job runs against the application database. +# +# Build/run example: +# docker build -f docker/db/Dockerfile -t ocotillo-db-prod . +# docker run -e POSTGRES_DB=ocotilloapi ocotillo-db-prod +# +# On Google Cloud SQL, pg_cron is enabled via the cloudsql.enable_pg_cron flag +# instead of this image; see docs/pg_cron-nightly-refresh.md. FROM postgis/postgis:17-3.5 + +# Install the pg_cron extension for PostgreSQL 17. +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-17-cron \ + && rm -rf /var/lib/apt/lists/* + +# Load pg_cron at server start and point its scheduler at the application +# database. POSTGRES_DB defaults to ocotilloapi here but can be overridden at +# run time; keep cron.database_name aligned with the database the API uses. +ENV POSTGRES_DB=ocotilloapi +CMD ["postgres", "-c", "shared_preload_libraries=pg_cron", "-c", "cron.database_name=ocotilloapi"] diff --git a/docs/pg_cron-nightly-refresh.md b/docs/pg_cron-nightly-refresh.md new file mode 100644 index 000000000..5a0015796 --- /dev/null +++ b/docs/pg_cron-nightly-refresh.md @@ -0,0 +1,100 @@ +# Nightly materialized-view refresh with pg_cron + +The pygeoapi materialized views (`ogc_latest_depth_to_water_wells`, +`ogc_water_elevation_wells`, `ogc_avg_tds_wells`, +`ogc_depth_to_water_trend_wells`, `ogc_water_well_summary`, +`ogc_major_chemistry_results`, `ogc_minor_chemistry_wells`) are refreshed once +a night in production by a [pg_cron](https://github.com/citusdata/pg_cron) job. + +## What is registered, and where + +Alembic migration +[`x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py`](../alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py) +registers everything, so the schedule is traceable in version control: + +- A SQL helper, `public.refresh_pygeoapi_materialized_views()`, that runs + `REFRESH MATERIALIZED VIEW` for each view (plain, non-concurrent — see note). +- A pg_cron job named `refresh-pygeoapi-materialized-views` that runs + `SELECT public.refresh_pygeoapi_materialized_views();` on the schedule + `0 9 * * *` (09:00 in the **server timezone**, UTC on Cloud SQL and the + production image — roughly 02:00–03:00 US Mountain). + +The view set comes from +[`services/materialized_views.py`](../services/materialized_views.py) +(`PYGEOAPI_MATERIALIZED_VIEWS`) — the single source of truth shared with the +`oco refresh-pygeoapi-materialized-views` CLI command. To change which views are +refreshed, edit that tuple. To change the schedule, edit the migration (or add a +new one). Do not edit the job in the database by hand, or it will drift from the +repo. + +## Why it is gated by `ENABLE_PG_CRON` + +pg_cron is a **production-only** dependency. It must be loaded through the +server's `shared_preload_libraries`, which the development docker-compose +Postgres image (`postgis/postgis:17-3.5`) does not do. Running +`CREATE EXTENSION pg_cron` without that preload fails. + +So the migration is a **no-op unless `ENABLE_PG_CRON` is truthy**: + +- Development, test, CI: `ENABLE_PG_CRON` unset → migration prints a skip + message and records itself as applied. `alembic upgrade head` works on the + stock dev image with nothing extra installed. +- Production: `ENABLE_PG_CRON=1` → migration creates the extension, the helper + function, and the cron job. + +## Production setup + +### Self-hosted / Docker + +Use the production database image, which installs pg_cron and preloads it: + +- [`docker/db/Dockerfile`](../docker/db/Dockerfile) installs + `postgresql-17-cron` and starts Postgres with + `-c shared_preload_libraries=pg_cron -c cron.database_name=ocotilloapi`. + +`cron.database_name` must match the application database so the alembic +migration (which connects to that database) can `CREATE EXTENSION pg_cron` and +`cron.schedule(...)` locally. Then deploy with `ENABLE_PG_CRON=1` set for the +app container that runs migrations. + +### Google Cloud SQL + +Do not use the Docker image; enable pg_cron with the instance flag instead: + +1. Set the flag `cloudsql.enable_pg_cron=on` and + `cron.database_name=`, then restart the instance. +2. Deploy the app with `ENABLE_PG_CRON=1` so the migration registers the job. + +## Verifying + +```sql +-- the registered job +SELECT jobid, jobname, schedule, command, active FROM cron.job + WHERE jobname = 'refresh-pygeoapi-materialized-views'; + +-- recent run history +SELECT status, start_time, end_time, return_message + FROM cron.job_run_details + WHERE jobid = (SELECT jobid FROM cron.job + WHERE jobname = 'refresh-pygeoapi-materialized-views') + ORDER BY start_time DESC LIMIT 5; +``` + +## Manual / ad-hoc refresh + +Independent of the cron job, the views can be refreshed on demand with the CLI +(also useful in development, where the cron job does not exist): + +```bash +oco refresh-pygeoapi-materialized-views # all views, plain +oco refresh-pygeoapi-materialized-views --concurrently # no read lock +``` + +### Note on non-concurrent REFRESH + +The cron helper uses plain `REFRESH MATERIALIZED VIEW`, not `CONCURRENTLY`, +because `REFRESH ... CONCURRENTLY` cannot run inside the implicit transaction of +a PL/pgSQL function. Plain refresh takes a brief exclusive lock on each view, +which is acceptable in the off-peak nightly window. The CLI still offers +`--concurrently` for daytime manual refreshes (every view has the required +unique index). diff --git a/services/materialized_views.py b/services/materialized_views.py new file mode 100644 index 000000000..ddc49322c --- /dev/null +++ b/services/materialized_views.py @@ -0,0 +1,17 @@ +"""Single source of truth for the pygeoapi materialized views. + +Both the ``oco refresh-pygeoapi-materialized-views`` CLI command and the +pg_cron nightly-refresh alembic migration import this tuple so the view set is +defined in exactly one place. Add or remove a view here and both stay in sync. +""" + +# Order is the order views are refreshed in. +PYGEOAPI_MATERIALIZED_VIEWS: tuple[str, ...] = ( + "ogc_latest_depth_to_water_wells", + "ogc_water_elevation_wells", + "ogc_avg_tds_wells", + "ogc_depth_to_water_trend_wells", + "ogc_water_well_summary", + "ogc_major_chemistry_results", + "ogc_minor_chemistry_wells", +) From 5ad3f25d7c28b132ac64e66b5c2ecf4578962931 Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:22:19 +0000 Subject: [PATCH 054/160] Formatting changes --- ...4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py index e85428869..159a2c602 100644 --- a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py +++ b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py @@ -58,7 +58,9 @@ def _build_refresh_function_sql() -> str: if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): raise ValueError(f"Invalid materialized view name: {name!r}") - array_literal = ",\n ".join(f"'{name}'" for name in PYGEOAPI_MATERIALIZED_VIEWS) + array_literal = ",\n ".join( + f"'{name}'" for name in PYGEOAPI_MATERIALIZED_VIEWS + ) return f""" CREATE OR REPLACE FUNCTION public.refresh_pygeoapi_materialized_views() RETURNS void @@ -106,8 +108,7 @@ def upgrade() -> None: # migration (or a re-deploy) does not accumulate duplicate schedules. op.execute( text( - "SELECT cron.unschedule(jobid) FROM cron.job " - "WHERE jobname = :name" + "SELECT cron.unschedule(jobid) FROM cron.job " "WHERE jobname = :name" ).bindparams(name=CRON_JOB_NAME) ) @@ -132,8 +133,7 @@ def downgrade() -> None: op.execute( text( - "SELECT cron.unschedule(jobid) FROM cron.job " - "WHERE jobname = :name" + "SELECT cron.unschedule(jobid) FROM cron.job " "WHERE jobname = :name" ).bindparams(name=CRON_JOB_NAME) ) op.execute( From 3bb19baaf9bfbb79eab8ea72d0ec00c067909e59 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 17 Jun 2026 10:55:00 -0600 Subject: [PATCH 055/160] fix(ci): keep nightly pg_cron job production-only Staging refreshes the materialized views on each deploy (the existing "Refresh materialized views" CD step), so it does not need the nightly pg_cron job. Drop ENABLE_PG_CRON from CD_staging.yml; only production registers the cron job. Co-Authored-By: Claude Fable 5 --- .github/workflows/CD_staging.yml | 5 ----- docs/pg_cron-nightly-refresh.md | 10 ++++++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 0ec5baed0..047237d9d 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -55,11 +55,6 @@ jobs: CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" CLOUD_SQL_IAM_AUTH: true - # Register the nightly pg_cron materialized-view refresh job. - # Requires the Cloud SQL instance flag cloudsql.enable_pg_cron=on and - # cron.database_name set to CLOUD_SQL_DATABASE. See - # docs/pg_cron-nightly-refresh.md. - ENABLE_PG_CRON: "1" run: | uv run --no-dev alembic upgrade head diff --git a/docs/pg_cron-nightly-refresh.md b/docs/pg_cron-nightly-refresh.md index 5a0015796..838125abc 100644 --- a/docs/pg_cron-nightly-refresh.md +++ b/docs/pg_cron-nightly-refresh.md @@ -36,11 +36,13 @@ Postgres image (`postgis/postgis:17-3.5`) does not do. Running So the migration is a **no-op unless `ENABLE_PG_CRON` is truthy**: -- Development, test, CI: `ENABLE_PG_CRON` unset → migration prints a skip - message and records itself as applied. `alembic upgrade head` works on the - stock dev image with nothing extra installed. +- Development, test, CI, **and staging**: `ENABLE_PG_CRON` unset → migration + prints a skip message and records itself as applied. `alembic upgrade head` + works on the stock dev image with nothing extra installed. Staging refreshes + the views on each deploy instead (the "Refresh materialized views" CD step), + so it does not need the nightly job. - Production: `ENABLE_PG_CRON=1` → migration creates the extension, the helper - function, and the cron job. + function, and the cron job. Only `CD_production.yml` sets this. ## Production setup From a7025e5937e48c3957e354a77284428b85873edf Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 17 Jun 2026 11:11:53 -0600 Subject: [PATCH 056/160] refactor(db): address pg_cron PR review - Migration helper now discovers ogc_* materialized views from the catalog at run time instead of importing the mutable view tuple. Keeps the versioned migration immutable and self-contained, and auto-includes views added by later migrations. Resolves the cross-environment drift concern from review. - Production DB image derives cron.database_name from POSTGRES_DB via a start-postgres.sh entrypoint wrapper, so pg_cron tracks the same database the migration connects to even when POSTGRES_DB is overridden. - services/materialized_views.py is now the CLI's curated default only; docs updated to match. Co-Authored-By: Claude Fable 5 --- ...chedule_nightly_matview_refresh_pg_cron.py | 59 ++++++++----------- docker/db/Dockerfile | 8 ++- docker/db/start-postgres.sh | 10 ++++ docs/pg_cron-nightly-refresh.md | 23 ++++---- services/materialized_views.py | 9 +-- 5 files changed, 58 insertions(+), 51 deletions(-) create mode 100644 docker/db/start-postgres.sh diff --git a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py index 159a2c602..e9271ba3d 100644 --- a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py +++ b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py @@ -1,9 +1,11 @@ """schedule nightly materialized-view refresh via pg_cron Registers a pg_cron job that refreshes the pygeoapi materialized views -once a night. The job is created through a SQL helper function, -``public.refresh_pygeoapi_materialized_views()``, so the list of views and -the refresh logic live in the database and version control together. +once a night. The job calls a SQL helper function, +``public.refresh_pygeoapi_materialized_views()``, which discovers the +``ogc_*`` materialized views from the catalog at run time -- so this +migration stays immutable and self-contained, and views added by later +migrations are refreshed without any rescheduling. pg_cron is a *production-only* dependency. It requires the extension to be loaded via ``shared_preload_libraries`` on the database server, which the @@ -18,14 +20,12 @@ Create Date: 2026-06-17 00:00:00.000000 """ -import re from typing import Sequence, Union from alembic import op from sqlalchemy import text from services.env import get_bool_env -from services.materialized_views import PYGEOAPI_MATERIALIZED_VIEWS # revision identifiers, used by Alembic. revision: str = "x2y3z4a5b6c7" @@ -42,40 +42,31 @@ CRON_SCHEDULE = "0 9 * * *" -def _build_refresh_function_sql() -> str: - """Build the helper function body from the shared view list. - - The view set is owned by ``services.materialized_views`` (the single source - of truth shared with the CLI). Plain (non-concurrent) REFRESH is used - deliberately: REFRESH ... CONCURRENTLY cannot run inside the implicit - transaction of a PL/pgSQL function, and the nightly window tolerates the - brief exclusive lock. Each view is guarded by an existence check so a - missing view never aborts the whole run. - """ - for name in PYGEOAPI_MATERIALIZED_VIEWS: - # These names are baked into a SQL literal array below; validate them - # rather than trust the constant blindly. - if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): - raise ValueError(f"Invalid materialized view name: {name!r}") - - array_literal = ",\n ".join( - f"'{name}'" for name in PYGEOAPI_MATERIALIZED_VIEWS - ) - return f""" +# Helper function the cron job calls. It discovers the pygeoapi materialized +# views (the ``ogc_*`` views in the public schema) from the catalog at run time +# rather than from a baked-in list. This keeps the migration immutable and +# self-contained -- it does not depend on mutable application code, and views +# added by later migrations are picked up automatically without rescheduling. +# +# Plain (non-concurrent) REFRESH is used deliberately: REFRESH ... CONCURRENTLY +# cannot run inside the implicit transaction of a PL/pgSQL function, and the +# nightly window tolerates the brief exclusive lock. +_REFRESH_FUNCTION_SQL = r""" CREATE OR REPLACE FUNCTION public.refresh_pygeoapi_materialized_views() RETURNS void LANGUAGE plpgsql AS $func$ DECLARE - v text; - views text[] := ARRAY[ - {array_literal} - ]; + r record; BEGIN - FOREACH v IN ARRAY views LOOP - IF EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = v) THEN - EXECUTE format('REFRESH MATERIALIZED VIEW %I', v); - END IF; + FOR r IN + SELECT matviewname + FROM pg_matviews + WHERE schemaname = 'public' + AND matviewname LIKE 'ogc\_%' ESCAPE '\' + ORDER BY matviewname + LOOP + EXECUTE format('REFRESH MATERIALIZED VIEW %I', r.matviewname); END LOOP; END; $func$; @@ -102,7 +93,7 @@ def upgrade() -> None: op.execute(text("CREATE EXTENSION IF NOT EXISTS pg_cron")) # (Re)create the refresh helper. - op.execute(text(_build_refresh_function_sql())) + op.execute(text(_REFRESH_FUNCTION_SQL)) # Drop any previously registered job with the same name so re-running this # migration (or a re-deploy) does not accumulate duplicate schedules. diff --git a/docker/db/Dockerfile b/docker/db/Dockerfile index e250b7e3c..ffa2c8864 100644 --- a/docker/db/Dockerfile +++ b/docker/db/Dockerfile @@ -23,7 +23,9 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* # Load pg_cron at server start and point its scheduler at the application -# database. POSTGRES_DB defaults to ocotilloapi here but can be overridden at -# run time; keep cron.database_name aligned with the database the API uses. +# database. cron.database_name is derived from POSTGRES_DB at start time (see +# start-postgres.sh) so it stays aligned even when POSTGRES_DB is overridden. ENV POSTGRES_DB=ocotilloapi -CMD ["postgres", "-c", "shared_preload_libraries=pg_cron", "-c", "cron.database_name=ocotilloapi"] +COPY docker/db/start-postgres.sh /usr/local/bin/start-postgres.sh +RUN chmod +x /usr/local/bin/start-postgres.sh +ENTRYPOINT ["start-postgres.sh"] diff --git a/docker/db/start-postgres.sh b/docker/db/start-postgres.sh new file mode 100644 index 000000000..0582b271f --- /dev/null +++ b/docker/db/start-postgres.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Start Postgres with pg_cron preloaded and its scheduler pointed at the +# application database. cron.database_name is derived from POSTGRES_DB so that, +# when the image is run with POSTGRES_DB overridden, pg_cron watches the same +# database the app (and the alembic migration) connects to. +set -e + +exec docker-entrypoint.sh postgres \ + -c shared_preload_libraries=pg_cron \ + -c "cron.database_name=${POSTGRES_DB:-ocotilloapi}" diff --git a/docs/pg_cron-nightly-refresh.md b/docs/pg_cron-nightly-refresh.md index 838125abc..685adc402 100644 --- a/docs/pg_cron-nightly-refresh.md +++ b/docs/pg_cron-nightly-refresh.md @@ -12,20 +12,21 @@ Alembic migration [`x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py`](../alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py) registers everything, so the schedule is traceable in version control: -- A SQL helper, `public.refresh_pygeoapi_materialized_views()`, that runs - `REFRESH MATERIALIZED VIEW` for each view (plain, non-concurrent — see note). +- A SQL helper, `public.refresh_pygeoapi_materialized_views()`, that discovers + the `ogc_*` materialized views from the catalog at run time and runs + `REFRESH MATERIALIZED VIEW` for each (plain, non-concurrent — see note). - A pg_cron job named `refresh-pygeoapi-materialized-views` that runs `SELECT public.refresh_pygeoapi_materialized_views();` on the schedule `0 9 * * *` (09:00 in the **server timezone**, UTC on Cloud SQL and the production image — roughly 02:00–03:00 US Mountain). -The view set comes from -[`services/materialized_views.py`](../services/materialized_views.py) -(`PYGEOAPI_MATERIALIZED_VIEWS`) — the single source of truth shared with the -`oco refresh-pygeoapi-materialized-views` CLI command. To change which views are -refreshed, edit that tuple. To change the schedule, edit the migration (or add a -new one). Do not edit the job in the database by hand, or it will drift from the -repo. +The helper refreshes whatever `ogc_*` materialized views exist, so a view added +by a later migration is picked up automatically — there is nothing to keep in +sync and no need to reschedule. (The `oco refresh-pygeoapi-materialized-views` +CLI command, used for manual/on-deploy refreshes, keeps an explicit curated +list in [`services/materialized_views.py`](../services/materialized_views.py).) +To change the schedule, edit the migration (or add a new one). Do not edit the +job in the database by hand, or it will drift from the repo. ## Why it is gated by `ENABLE_PG_CRON` @@ -52,7 +53,9 @@ Use the production database image, which installs pg_cron and preloads it: - [`docker/db/Dockerfile`](../docker/db/Dockerfile) installs `postgresql-17-cron` and starts Postgres with - `-c shared_preload_libraries=pg_cron -c cron.database_name=ocotilloapi`. + `-c shared_preload_libraries=pg_cron -c cron.database_name=$POSTGRES_DB` + (via [`start-postgres.sh`](../docker/db/start-postgres.sh), so overriding + `POSTGRES_DB` keeps the scheduler pointed at the same database). `cron.database_name` must match the application database so the alembic migration (which connects to that database) can `CREATE EXTENSION pg_cron` and diff --git a/services/materialized_views.py b/services/materialized_views.py index ddc49322c..4ce701599 100644 --- a/services/materialized_views.py +++ b/services/materialized_views.py @@ -1,8 +1,9 @@ -"""Single source of truth for the pygeoapi materialized views. +"""Curated pygeoapi materialized-view list for the CLI refresh command. -Both the ``oco refresh-pygeoapi-materialized-views`` CLI command and the -pg_cron nightly-refresh alembic migration import this tuple so the view set is -defined in exactly one place. Add or remove a view here and both stay in sync. +``oco refresh-pygeoapi-materialized-views`` refreshes these views (in order) +by default. The nightly pg_cron job does NOT use this list -- its SQL helper +discovers the ``ogc_*`` materialized views from the catalog at run time (see +alembic migration ``x2y3z4a5b6c7``) to stay immutable and self-contained. """ # Order is the order views are refreshed in. From fc990afa14659bcd8fb3ed277d19d2ddb58a3014 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 17 Jun 2026 11:18:03 -0600 Subject: [PATCH 057/160] feat(db): refresh all materialized views, not just pygeoapi Refresh every materialized view nightly, including transducer_daily_data. - Migration helper drops the ogc_* filter and refreshes all public-schema materialized views discovered from the catalog at run time. - Rename PYGEOAPI_MATERIALIZED_VIEWS -> MATERIALIZED_VIEWS and add transducer_daily_data to the CLI's default list. - Update CLI refresh test expectations (8 views) and docs. Co-Authored-By: Claude Fable 5 --- ...chedule_nightly_matview_refresh_pg_cron.py | 23 +++++++++---------- cli/cli.py | 4 ++-- docs/pg_cron-nightly-refresh.md | 22 ++++++++---------- services/materialized_views.py | 9 ++++---- tests/test_cli_commands.py | 3 ++- 5 files changed, 30 insertions(+), 31 deletions(-) diff --git a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py index e9271ba3d..3a66b71a3 100644 --- a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py +++ b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py @@ -1,11 +1,11 @@ """schedule nightly materialized-view refresh via pg_cron -Registers a pg_cron job that refreshes the pygeoapi materialized views -once a night. The job calls a SQL helper function, -``public.refresh_pygeoapi_materialized_views()``, which discovers the -``ogc_*`` materialized views from the catalog at run time -- so this -migration stays immutable and self-contained, and views added by later -migrations are refreshed without any rescheduling. +Registers a pg_cron job that refreshes the materialized views once a +night. The job calls a SQL helper function, +``public.refresh_pygeoapi_materialized_views()``, which discovers every +materialized view in the public schema from the catalog at run time -- so +this migration stays immutable and self-contained, and views added by +later migrations are refreshed without any rescheduling. pg_cron is a *production-only* dependency. It requires the extension to be loaded via ``shared_preload_libraries`` on the database server, which the @@ -42,11 +42,11 @@ CRON_SCHEDULE = "0 9 * * *" -# Helper function the cron job calls. It discovers the pygeoapi materialized -# views (the ``ogc_*`` views in the public schema) from the catalog at run time -# rather than from a baked-in list. This keeps the migration immutable and -# self-contained -- it does not depend on mutable application code, and views -# added by later migrations are picked up automatically without rescheduling. +# Helper function the cron job calls. It discovers every materialized view in +# the public schema from the catalog at run time rather than from a baked-in +# list. This keeps the migration immutable and self-contained -- it does not +# depend on mutable application code, and views added by later migrations are +# picked up automatically without rescheduling. # # Plain (non-concurrent) REFRESH is used deliberately: REFRESH ... CONCURRENTLY # cannot run inside the implicit transaction of a PL/pgSQL function, and the @@ -63,7 +63,6 @@ SELECT matviewname FROM pg_matviews WHERE schemaname = 'public' - AND matviewname LIKE 'ogc\_%' ESCAPE '\' ORDER BY matviewname LOOP EXECUTE format('REFRESH MATERIALIZED VIEW %I', r.matviewname); diff --git a/cli/cli.py b/cli/cli.py index f68857a5a..44bab91ed 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -24,7 +24,7 @@ import typer from dotenv import load_dotenv -from services.materialized_views import PYGEOAPI_MATERIALIZED_VIEWS +from services.materialized_views import MATERIALIZED_VIEWS # CLI should load `.env` defaults without clobbering an explicitly prepared environment. load_dotenv(override=False) @@ -1106,7 +1106,7 @@ def refresh_pygeoapi_materialized_views( from db.engine import engine, session_ctx - target_views = tuple(view) if view else PYGEOAPI_MATERIALIZED_VIEWS + target_views = tuple(view) if view else MATERIALIZED_VIEWS # Validate all view names before opening any DB connections or sessions. safe_views = tuple(_validate_sql_identifier(v) for v in target_views) diff --git a/docs/pg_cron-nightly-refresh.md b/docs/pg_cron-nightly-refresh.md index 685adc402..d6a05f236 100644 --- a/docs/pg_cron-nightly-refresh.md +++ b/docs/pg_cron-nightly-refresh.md @@ -1,10 +1,8 @@ # Nightly materialized-view refresh with pg_cron -The pygeoapi materialized views (`ogc_latest_depth_to_water_wells`, -`ogc_water_elevation_wells`, `ogc_avg_tds_wells`, -`ogc_depth_to_water_trend_wells`, `ogc_water_well_summary`, -`ogc_major_chemistry_results`, `ogc_minor_chemistry_wells`) are refreshed once -a night in production by a [pg_cron](https://github.com/citusdata/pg_cron) job. +Every materialized view in the database (the `ogc_*` pygeoapi views and +`transducer_daily_data`) is refreshed once a night in production by a +[pg_cron](https://github.com/citusdata/pg_cron) job. ## What is registered, and where @@ -13,18 +11,18 @@ Alembic migration registers everything, so the schedule is traceable in version control: - A SQL helper, `public.refresh_pygeoapi_materialized_views()`, that discovers - the `ogc_*` materialized views from the catalog at run time and runs - `REFRESH MATERIALIZED VIEW` for each (plain, non-concurrent — see note). + every materialized view in the public schema from the catalog at run time and + runs `REFRESH MATERIALIZED VIEW` for each (plain, non-concurrent — see note). - A pg_cron job named `refresh-pygeoapi-materialized-views` that runs `SELECT public.refresh_pygeoapi_materialized_views();` on the schedule `0 9 * * *` (09:00 in the **server timezone**, UTC on Cloud SQL and the production image — roughly 02:00–03:00 US Mountain). -The helper refreshes whatever `ogc_*` materialized views exist, so a view added -by a later migration is picked up automatically — there is nothing to keep in -sync and no need to reschedule. (The `oco refresh-pygeoapi-materialized-views` -CLI command, used for manual/on-deploy refreshes, keeps an explicit curated -list in [`services/materialized_views.py`](../services/materialized_views.py).) +The helper refreshes whatever materialized views exist, so a view added by a +later migration is picked up automatically — there is nothing to keep in sync +and no need to reschedule. (The `oco refresh-pygeoapi-materialized-views` CLI +command, used for manual/on-deploy refreshes, keeps an explicit list in +[`services/materialized_views.py`](../services/materialized_views.py).) To change the schedule, edit the migration (or add a new one). Do not edit the job in the database by hand, or it will drift from the repo. diff --git a/services/materialized_views.py b/services/materialized_views.py index 4ce701599..e72b70d4e 100644 --- a/services/materialized_views.py +++ b/services/materialized_views.py @@ -1,13 +1,13 @@ -"""Curated pygeoapi materialized-view list for the CLI refresh command. +"""Curated materialized-view list for the CLI refresh command. ``oco refresh-pygeoapi-materialized-views`` refreshes these views (in order) by default. The nightly pg_cron job does NOT use this list -- its SQL helper -discovers the ``ogc_*`` materialized views from the catalog at run time (see -alembic migration ``x2y3z4a5b6c7``) to stay immutable and self-contained. +discovers every materialized view from the catalog at run time (see alembic +migration ``x2y3z4a5b6c7``) to stay immutable and self-contained. """ # Order is the order views are refreshed in. -PYGEOAPI_MATERIALIZED_VIEWS: tuple[str, ...] = ( +MATERIALIZED_VIEWS: tuple[str, ...] = ( "ogc_latest_depth_to_water_wells", "ogc_water_elevation_wells", "ogc_avg_tds_wells", @@ -15,4 +15,5 @@ "ogc_water_well_summary", "ogc_major_chemistry_results", "ogc_minor_chemistry_wells", + "transducer_daily_data", ) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 5953c0f2e..7a7707f30 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -70,9 +70,10 @@ 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 transducer_daily_data", ] assert commit_called["value"] is True - assert "Refreshed 7 materialized view(s)." in result.output + assert "Refreshed 8 materialized view(s)." in result.output def test_refresh_pygeoapi_materialized_views_custom_and_concurrently( From ce34ca4a7ee58de0bf711cbef2bf439441c3a5d8 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 17 Jun 2026 11:24:51 -0600 Subject: [PATCH 058/160] refactor: drop pygeoapi-specific naming for matview refresh The refresh covers all materialized views, not just the ogc_* pygeoapi views, so rename the pygeoapi-specific identifiers to generic ones. - CLI command refresh-pygeoapi-materialized-views -> refresh-materialized-views (function refresh_pygeoapi_materialized_views -> refresh_materialized_views). - SQL helper public.refresh_pygeoapi_materialized_views() -> public.refresh_materialized_views(); rename it in d5e6f7a8b9c0 too, and have the pg_cron migration drop the legacy function on databases that already created it. - Cron job name refresh-pygeoapi-materialized-views -> refresh-materialized-views. - Update CD workflows (staging/testing/production), tests, and docs. Co-Authored-By: Claude Fable 5 --- .github/workflows/CD_production.yml | 2 +- .github/workflows/CD_staging.yml | 2 +- .github/workflows/CD_testing.yml | 2 +- ...a8b9c0_create_pygeoapi_supporting_views.py | 2 +- ...chedule_nightly_matview_refresh_pg_cron.py | 20 ++++++++++++++----- cli/cli.py | 6 +++--- docs/pg_cron-nightly-refresh.md | 18 ++++++++--------- services/materialized_views.py | 2 +- tests/test_cli_commands.py | 12 +++++------ 9 files changed, 38 insertions(+), 28 deletions(-) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 1160c140c..1ade7f251 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -106,7 +106,7 @@ jobs: CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" CLOUD_SQL_IAM_AUTH: true run: | - uv run --no-dev python -m cli.cli refresh-pygeoapi-materialized-views + uv run --no-dev python -m cli.cli refresh-materialized-views - name: Ensure envsubst is available run: | diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 047237d9d..e55c6f2a4 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -66,7 +66,7 @@ jobs: CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" CLOUD_SQL_IAM_AUTH: true run: | - uv run --no-dev python -m cli.cli refresh-pygeoapi-materialized-views + uv run --no-dev python -m cli.cli refresh-materialized-views - name: Ensure envsubst is available run: | diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index 66c96a2ce..64e15443e 100644 --- a/.github/workflows/CD_testing.yml +++ b/.github/workflows/CD_testing.yml @@ -66,7 +66,7 @@ jobs: CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" CLOUD_SQL_IAM_AUTH: true run: | - uv run --no-dev python -m cli.cli refresh-pygeoapi-materialized-views + uv run --no-dev python -m cli.cli refresh-materialized-views - name: Ensure envsubst is available run: | diff --git a/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py b/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py index 60d03fc04..d8e12b2bc 100644 --- a/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py +++ b/alembic/versions/d5e6f7a8b9c0_create_pygeoapi_supporting_views.py @@ -16,7 +16,7 @@ down_revision: Union[str, Sequence[str], None] = "c4d5e6f7a8b9" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None -REFRESH_FUNCTION_NAME = "refresh_pygeoapi_materialized_views" +REFRESH_FUNCTION_NAME = "refresh_materialized_views" THING_COLLECTIONS = [ ("water_wells", "water well"), diff --git a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py index 3a66b71a3..6ac1f66f0 100644 --- a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py +++ b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py @@ -2,11 +2,15 @@ Registers a pg_cron job that refreshes the materialized views once a night. The job calls a SQL helper function, -``public.refresh_pygeoapi_materialized_views()``, which discovers every +``public.refresh_materialized_views()``, which discovers every materialized view in the public schema from the catalog at run time -- so this migration stays immutable and self-contained, and views added by later migrations are refreshed without any rescheduling. +This also drops the legacy ``refresh_pygeoapi_materialized_views`` helper +(created by ``d5e6f7a8b9c0``) on databases that already ran that revision, +folding it into the generically named function. + pg_cron is a *production-only* dependency. It requires the extension to be loaded via ``shared_preload_libraries`` on the database server, which the development docker-compose Postgres image does not do. To avoid breaking @@ -34,7 +38,10 @@ depends_on: Union[str, Sequence[str], None] = None # Name of the pg_cron job. Used to (re)register and to unschedule. -CRON_JOB_NAME = "refresh-pygeoapi-materialized-views" +CRON_JOB_NAME = "refresh-materialized-views" + +# Legacy helper created by d5e6f7a8b9c0, superseded by refresh_materialized_views. +LEGACY_FUNCTION_NAME = "refresh_pygeoapi_materialized_views" # Nightly schedule in standard cron syntax. pg_cron interprets this in the # database server's timezone (UTC on Cloud SQL / the docker image), so 09:00 @@ -52,7 +59,7 @@ # cannot run inside the implicit transaction of a PL/pgSQL function, and the # nightly window tolerates the brief exclusive lock. _REFRESH_FUNCTION_SQL = r""" -CREATE OR REPLACE FUNCTION public.refresh_pygeoapi_materialized_views() +CREATE OR REPLACE FUNCTION public.refresh_materialized_views() RETURNS void LANGUAGE plpgsql AS $func$ @@ -94,6 +101,9 @@ def upgrade() -> None: # (Re)create the refresh helper. op.execute(text(_REFRESH_FUNCTION_SQL)) + # Remove the legacy helper on databases that already ran d5e6f7a8b9c0. + op.execute(text(f"DROP FUNCTION IF EXISTS public.{LEGACY_FUNCTION_NAME}()")) + # Drop any previously registered job with the same name so re-running this # migration (or a re-deploy) does not accumulate duplicate schedules. op.execute( @@ -106,7 +116,7 @@ def upgrade() -> None: text("SELECT cron.schedule(:name, :sched, :cmd)").bindparams( name=CRON_JOB_NAME, sched=CRON_SCHEDULE, - cmd="SELECT public.refresh_pygeoapi_materialized_views();", + cmd="SELECT public.refresh_materialized_views();", ) ) @@ -127,7 +137,7 @@ def downgrade() -> None: ).bindparams(name=CRON_JOB_NAME) ) op.execute( - text("DROP FUNCTION IF EXISTS public.refresh_pygeoapi_materialized_views()") + text("DROP FUNCTION IF EXISTS public.refresh_materialized_views()") ) # The pg_cron extension itself is left installed: it is a server-level # capability that other jobs may depend on, and dropping it is not the diff --git a/cli/cli.py b/cli/cli.py index 44bab91ed..14c8f9470 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -1086,14 +1086,14 @@ def alembic_upgrade_and_data( typer.echo(f"applied {len(ran)} migration(s)") -@cli.command("refresh-pygeoapi-materialized-views") -def refresh_pygeoapi_materialized_views( +@cli.command("refresh-materialized-views") +def refresh_materialized_views( view: list[str] = typer.Option( None, "--view", help=( "Materialized view name(s) to refresh. Repeat --view for multiple. " - "Defaults to all pygeoapi materialized views." + "Defaults to all materialized views." ), ), concurrently: bool = typer.Option( diff --git a/docs/pg_cron-nightly-refresh.md b/docs/pg_cron-nightly-refresh.md index d6a05f236..15b81c634 100644 --- a/docs/pg_cron-nightly-refresh.md +++ b/docs/pg_cron-nightly-refresh.md @@ -1,6 +1,6 @@ # Nightly materialized-view refresh with pg_cron -Every materialized view in the database (the `ogc_*` pygeoapi views and +Every materialized view in the database (the `ogc_*` views and `transducer_daily_data`) is refreshed once a night in production by a [pg_cron](https://github.com/citusdata/pg_cron) job. @@ -10,17 +10,17 @@ Alembic migration [`x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py`](../alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py) registers everything, so the schedule is traceable in version control: -- A SQL helper, `public.refresh_pygeoapi_materialized_views()`, that discovers +- A SQL helper, `public.refresh_materialized_views()`, that discovers every materialized view in the public schema from the catalog at run time and runs `REFRESH MATERIALIZED VIEW` for each (plain, non-concurrent — see note). -- A pg_cron job named `refresh-pygeoapi-materialized-views` that runs - `SELECT public.refresh_pygeoapi_materialized_views();` on the schedule +- A pg_cron job named `refresh-materialized-views` that runs + `SELECT public.refresh_materialized_views();` on the schedule `0 9 * * *` (09:00 in the **server timezone**, UTC on Cloud SQL and the production image — roughly 02:00–03:00 US Mountain). The helper refreshes whatever materialized views exist, so a view added by a later migration is picked up automatically — there is nothing to keep in sync -and no need to reschedule. (The `oco refresh-pygeoapi-materialized-views` CLI +and no need to reschedule. (The `oco refresh-materialized-views` CLI command, used for manual/on-deploy refreshes, keeps an explicit list in [`services/materialized_views.py`](../services/materialized_views.py).) To change the schedule, edit the migration (or add a new one). Do not edit the @@ -73,13 +73,13 @@ Do not use the Docker image; enable pg_cron with the instance flag instead: ```sql -- the registered job SELECT jobid, jobname, schedule, command, active FROM cron.job - WHERE jobname = 'refresh-pygeoapi-materialized-views'; + WHERE jobname = 'refresh-materialized-views'; -- recent run history SELECT status, start_time, end_time, return_message FROM cron.job_run_details WHERE jobid = (SELECT jobid FROM cron.job - WHERE jobname = 'refresh-pygeoapi-materialized-views') + WHERE jobname = 'refresh-materialized-views') ORDER BY start_time DESC LIMIT 5; ``` @@ -89,8 +89,8 @@ Independent of the cron job, the views can be refreshed on demand with the CLI (also useful in development, where the cron job does not exist): ```bash -oco refresh-pygeoapi-materialized-views # all views, plain -oco refresh-pygeoapi-materialized-views --concurrently # no read lock +oco refresh-materialized-views # all views, plain +oco refresh-materialized-views --concurrently # no read lock ``` ### Note on non-concurrent REFRESH diff --git a/services/materialized_views.py b/services/materialized_views.py index e72b70d4e..ec1ae7103 100644 --- a/services/materialized_views.py +++ b/services/materialized_views.py @@ -1,6 +1,6 @@ """Curated materialized-view list for the CLI refresh command. -``oco refresh-pygeoapi-materialized-views`` refreshes these views (in order) +``oco refresh-materialized-views`` refreshes these views (in order) by default. The nightly pg_cron job does NOT use this list -- its SQL helper discovers every materialized view from the catalog at run time (see alembic migration ``x2y3z4a5b6c7``) to stay immutable and self-contained. diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 7a7707f30..f64a81306 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -38,7 +38,7 @@ from db.engine import session_ctx -def test_refresh_pygeoapi_materialized_views_defaults(monkeypatch): +def test_refresh_materialized_views_defaults(monkeypatch): executed_sql: list[str] = [] commit_called = {"value": False} @@ -59,7 +59,7 @@ def __exit__(self, exc_type, exc, tb): monkeypatch.setattr("db.engine.session_ctx", lambda: _FakeCtx()) runner = CliRunner() - result = runner.invoke(cli, ["refresh-pygeoapi-materialized-views"]) + result = runner.invoke(cli, ["refresh-materialized-views"]) assert result.exit_code == 0, result.output assert executed_sql == [ @@ -76,7 +76,7 @@ def __exit__(self, exc_type, exc, tb): assert "Refreshed 8 materialized view(s)." in result.output -def test_refresh_pygeoapi_materialized_views_custom_and_concurrently( +def test_refresh_materialized_views_custom_and_concurrently( monkeypatch, ): executed_sql: list[str] = [] @@ -106,7 +106,7 @@ def connect(self): result = runner.invoke( cli, [ - "refresh-pygeoapi-materialized-views", + "refresh-materialized-views", "--view", "ogc_avg_tds_wells", "--concurrently", @@ -120,12 +120,12 @@ def connect(self): ] -def test_refresh_pygeoapi_materialized_views_rejects_invalid_identifier(): +def test_refresh_materialized_views_rejects_invalid_identifier(): runner = CliRunner() result = runner.invoke( cli, [ - "refresh-pygeoapi-materialized-views", + "refresh-materialized-views", "--view", "ogc_avg_tds_wells;drop table thing", ], From 18e8b790bc4881278f2effb260caa0db30d6b6ea Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:25:20 +0000 Subject: [PATCH 059/160] Formatting changes --- .../x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py index 6ac1f66f0..e41523bd3 100644 --- a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py +++ b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py @@ -136,9 +136,7 @@ def downgrade() -> None: "SELECT cron.unschedule(jobid) FROM cron.job " "WHERE jobname = :name" ).bindparams(name=CRON_JOB_NAME) ) - op.execute( - text("DROP FUNCTION IF EXISTS public.refresh_materialized_views()") - ) + op.execute(text("DROP FUNCTION IF EXISTS public.refresh_materialized_views()")) # The pg_cron extension itself is left installed: it is a server-level # capability that other jobs may depend on, and dropping it is not the # inverse of "schedule a job". From 2526a8313b3b7b211237ced1397ceb5a902055ac Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 17 Jun 2026 11:26:08 -0600 Subject: [PATCH 060/160] revert(docker): keep db image dev-only, drop pg_cron from it The docker/db image is used only for development, so it should not carry the production pg_cron setup. Revert it to the stock postgis image, remove the start-postgres.sh wrapper, and document pg_cron as Cloud SQL-only in production. Co-Authored-By: Claude Fable 5 --- ...chedule_nightly_matview_refresh_pg_cron.py | 4 +-- docker/db/Dockerfile | 30 ------------------ docker/db/start-postgres.sh | 10 ------ docs/pg_cron-nightly-refresh.md | 31 ++++++------------- 4 files changed, 12 insertions(+), 63 deletions(-) delete mode 100644 docker/db/start-postgres.sh diff --git a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py index e41523bd3..1797b7936 100644 --- a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py +++ b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py @@ -44,8 +44,8 @@ LEGACY_FUNCTION_NAME = "refresh_pygeoapi_materialized_views" # Nightly schedule in standard cron syntax. pg_cron interprets this in the -# database server's timezone (UTC on Cloud SQL / the docker image), so 09:00 -# UTC is roughly 02:00-03:00 in US Mountain time -- comfortably off-peak. +# database server's timezone (UTC on Cloud SQL), so 09:00 UTC is roughly +# 02:00-03:00 in US Mountain time -- comfortably off-peak. CRON_SCHEDULE = "0 9 * * *" diff --git a/docker/db/Dockerfile b/docker/db/Dockerfile index ffa2c8864..4a1fbd51b 100644 --- a/docker/db/Dockerfile +++ b/docker/db/Dockerfile @@ -1,31 +1 @@ -# Production database image: PostGIS + pg_cron. -# -# This image is intentionally NOT used by the development docker-compose -# service (which runs the stock postgis/postgis image). pg_cron is a -# production-only dependency required by the nightly materialized-view refresh -# job registered in alembic migration x2y3z4a5b6c7. -# -# pg_cron must be loaded via shared_preload_libraries, and its background -# worker schedules jobs in a single database (cron.database_name). Both are set -# below so the alembic-registered job runs against the application database. -# -# Build/run example: -# docker build -f docker/db/Dockerfile -t ocotillo-db-prod . -# docker run -e POSTGRES_DB=ocotilloapi ocotillo-db-prod -# -# On Google Cloud SQL, pg_cron is enabled via the cloudsql.enable_pg_cron flag -# instead of this image; see docs/pg_cron-nightly-refresh.md. FROM postgis/postgis:17-3.5 - -# Install the pg_cron extension for PostgreSQL 17. -RUN apt-get update \ - && apt-get install -y --no-install-recommends postgresql-17-cron \ - && rm -rf /var/lib/apt/lists/* - -# Load pg_cron at server start and point its scheduler at the application -# database. cron.database_name is derived from POSTGRES_DB at start time (see -# start-postgres.sh) so it stays aligned even when POSTGRES_DB is overridden. -ENV POSTGRES_DB=ocotilloapi -COPY docker/db/start-postgres.sh /usr/local/bin/start-postgres.sh -RUN chmod +x /usr/local/bin/start-postgres.sh -ENTRYPOINT ["start-postgres.sh"] diff --git a/docker/db/start-postgres.sh b/docker/db/start-postgres.sh deleted file mode 100644 index 0582b271f..000000000 --- a/docker/db/start-postgres.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -# Start Postgres with pg_cron preloaded and its scheduler pointed at the -# application database. cron.database_name is derived from POSTGRES_DB so that, -# when the image is run with POSTGRES_DB overridden, pg_cron watches the same -# database the app (and the alembic migration) connects to. -set -e - -exec docker-entrypoint.sh postgres \ - -c shared_preload_libraries=pg_cron \ - -c "cron.database_name=${POSTGRES_DB:-ocotilloapi}" diff --git a/docs/pg_cron-nightly-refresh.md b/docs/pg_cron-nightly-refresh.md index 15b81c634..8c82a9edd 100644 --- a/docs/pg_cron-nightly-refresh.md +++ b/docs/pg_cron-nightly-refresh.md @@ -15,8 +15,8 @@ registers everything, so the schedule is traceable in version control: runs `REFRESH MATERIALIZED VIEW` for each (plain, non-concurrent — see note). - A pg_cron job named `refresh-materialized-views` that runs `SELECT public.refresh_materialized_views();` on the schedule - `0 9 * * *` (09:00 in the **server timezone**, UTC on Cloud SQL and the - production image — roughly 02:00–03:00 US Mountain). + `0 9 * * *` (09:00 in the **server timezone**, UTC on Cloud SQL — + roughly 02:00–03:00 US Mountain). The helper refreshes whatever materialized views exist, so a view added by a later migration is picked up automatically — there is nothing to keep in sync @@ -43,30 +43,19 @@ So the migration is a **no-op unless `ENABLE_PG_CRON` is truthy**: - Production: `ENABLE_PG_CRON=1` → migration creates the extension, the helper function, and the cron job. Only `CD_production.yml` sets this. -## Production setup +## Production setup (Google Cloud SQL) -### Self-hosted / Docker +Production runs on Cloud SQL, where pg_cron is enabled with an instance flag +(the `docker/db/Dockerfile` image is development-only and does not load pg_cron): -Use the production database image, which installs pg_cron and preloads it: - -- [`docker/db/Dockerfile`](../docker/db/Dockerfile) installs - `postgresql-17-cron` and starts Postgres with - `-c shared_preload_libraries=pg_cron -c cron.database_name=$POSTGRES_DB` - (via [`start-postgres.sh`](../docker/db/start-postgres.sh), so overriding - `POSTGRES_DB` keeps the scheduler pointed at the same database). +1. Set the flag `cloudsql.enable_pg_cron=on` and + `cron.database_name=`, then restart the instance. +2. Deploy with `ENABLE_PG_CRON=1` (already set on the migration step in + `CD_production.yml`) so the migration registers the job. `cron.database_name` must match the application database so the alembic migration (which connects to that database) can `CREATE EXTENSION pg_cron` and -`cron.schedule(...)` locally. Then deploy with `ENABLE_PG_CRON=1` set for the -app container that runs migrations. - -### Google Cloud SQL - -Do not use the Docker image; enable pg_cron with the instance flag instead: - -1. Set the flag `cloudsql.enable_pg_cron=on` and - `cron.database_name=`, then restart the instance. -2. Deploy the app with `ENABLE_PG_CRON=1` so the migration registers the job. +`cron.schedule(...)` locally. ## Verifying From ef4335c97a9d7065b4e5d1f32ba830abdb57171a Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 17 Jun 2026 11:30:01 -0600 Subject: [PATCH 061/160] refactor: drop legacy-function cleanup from pg_cron migration The cron job was never deployed, so the migration does not need to drop a previously created refresh_pygeoapi_materialized_views helper. Remove the LEGACY_FUNCTION_NAME constant, the DROP in upgrade, and the related note. Co-Authored-By: Claude Fable 5 --- ...4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py index 1797b7936..0e50fdef9 100644 --- a/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py +++ b/alembic/versions/x2y3z4a5b6c7_schedule_nightly_matview_refresh_pg_cron.py @@ -7,10 +7,6 @@ this migration stays immutable and self-contained, and views added by later migrations are refreshed without any rescheduling. -This also drops the legacy ``refresh_pygeoapi_materialized_views`` helper -(created by ``d5e6f7a8b9c0``) on databases that already ran that revision, -folding it into the generically named function. - pg_cron is a *production-only* dependency. It requires the extension to be loaded via ``shared_preload_libraries`` on the database server, which the development docker-compose Postgres image does not do. To avoid breaking @@ -40,9 +36,6 @@ # Name of the pg_cron job. Used to (re)register and to unschedule. CRON_JOB_NAME = "refresh-materialized-views" -# Legacy helper created by d5e6f7a8b9c0, superseded by refresh_materialized_views. -LEGACY_FUNCTION_NAME = "refresh_pygeoapi_materialized_views" - # Nightly schedule in standard cron syntax. pg_cron interprets this in the # database server's timezone (UTC on Cloud SQL), so 09:00 UTC is roughly # 02:00-03:00 in US Mountain time -- comfortably off-peak. @@ -101,9 +94,6 @@ def upgrade() -> None: # (Re)create the refresh helper. op.execute(text(_REFRESH_FUNCTION_SQL)) - # Remove the legacy helper on databases that already ran d5e6f7a8b9c0. - op.execute(text(f"DROP FUNCTION IF EXISTS public.{LEGACY_FUNCTION_NAME}()")) - # Drop any previously registered job with the same name so re-running this # migration (or a re-deploy) does not accumulate duplicate schedules. op.execute( From 04b8ecb9dbd3d22fea952ba379625b796997c926 Mon Sep 17 00:00:00 2001 From: Peter Rowland Date: Wed, 17 Jun 2026 11:17:10 -0700 Subject: [PATCH 062/160] feat: add ogc_bht_measurements OGC collection Translates the legacy MSSQL BHT query to a PostgreSQL view returning one row per individual BHT measurement (not aggregated per well). Joins NMW_GtBhtData through headers, samples, records, Z-datum filter, well headers, and locations. Exposes 5 063 features via /ogcapi/collections/bht_measurements. Co-Authored-By: Claude Sonnet 4.6 --- ...7f8a9b0c1_add_ogc_bht_measurements_view.py | 65 +++++++++++++++++++ core/pygeoapi-config.yml | 23 +++++++ 2 files changed, 88 insertions(+) create mode 100644 alembic/versions/d6e7f8a9b0c1_add_ogc_bht_measurements_view.py diff --git a/alembic/versions/d6e7f8a9b0c1_add_ogc_bht_measurements_view.py b/alembic/versions/d6e7f8a9b0c1_add_ogc_bht_measurements_view.py new file mode 100644 index 000000000..ef5c037c1 --- /dev/null +++ b/alembic/versions/d6e7f8a9b0c1_add_ogc_bht_measurements_view.py @@ -0,0 +1,65 @@ +"""add ogc_bht_measurements view + +Revision ID: d6e7f8a9b0c1 +Revises: c5d6e7f8a9b0 +Create Date: 2026-06-17 + +Individual BHT measurement rows with well header, location, and Z-datum +filter — translated from the legacy MSSQL query against NM_Aquifer. +One row per measurement (not aggregated per well). +""" + +from alembic import op +from sqlalchemy import text + +revision = "d6e7f8a9b0c1" +down_revision = "c5d6e7f8a9b0" +branch_labels = None +depends_on = None + +_VIEW = "ogc_bht_measurements" + + +def upgrade() -> None: + op.execute(text(f'DROP VIEW IF EXISTS "{_VIEW}"')) + op.execute( + text( + f""" + CREATE VIEW "{_VIEW}" AS + SELECT + d."OBJECTID" AS id, + hdr."API" AS api, + hdr."CurWellNam" AS well_name, + hdr."CurWellNum" AS well_num, + hdr."CurOperatr" AS operator, + hdr."WellType" AS well_type, + hdr."Well_TVD" AS well_tvd, + hdr."ComplDate" AS completion_date, + hdr."CurStatus" AS current_status, + hdr."TotalDepth" AS total_depth, + hdr."Cuttings" AS cuttings, + hdr."CoreExists" AS core_exists, + loc."County" AS county, + d."Depth" AS bht_depth, + d."BHT" AS bht, + d."HrsSnceCir" AS hours_since_circulation, + d."DateMeasrd" AS date_measured, + ST_SetSRID( + ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326 + ) AS geom + FROM "NMW_GtBhtData" AS d + JOIN "NMW_GtBhtHeaders" AS bh ON bh."BHTGUID" = d."BHTGUID" + JOIN "NMW_WellSamples" AS s ON s."SamplSetID" = bh."SamplSetID" + JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = s."RecrdsetID" + JOIN "NMW_WellZDatum" AS z ON z."RecrdsetID" = r."RecrdSetID" + JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + JOIN "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + WHERE loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL + """ + ) + ) + + +def downgrade() -> None: + op.execute(text(f'DROP VIEW IF EXISTS "{_VIEW}"')) diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index 7464e6a08..66fcb7d83 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -334,3 +334,26 @@ resources: id_field: id table: ogc_geothermal_wells_temperature_profile geom_field: geom + + 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] + 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_bht_measurements + geom_field: geom From 10c522b824cc955190a3ace884d089dcaa1c0ae8 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 17 Jun 2026 14:09:44 -0600 Subject: [PATCH 063/160] feat: add workflow to close stale pull requests automatically --- .github/workflows/stale-prs.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/stale-prs.yml diff --git a/.github/workflows/stale-prs.yml b/.github/workflows/stale-prs.yml new file mode 100644 index 000000000..f2a9a3ffb --- /dev/null +++ b/.github/workflows/stale-prs.yml @@ -0,0 +1,24 @@ +name: Close stale PRs + +on: + schedule: + - cron: '0 9 * * *' # daily at 09:00 UTC + workflow_dispatch: # lets you trigger it manually too + +permissions: + pull-requests: write # needed to comment, label, and close + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v10 + with: + days-before-pr-stale: 14 + days-before-pr-close: 0 + stale-pr-message: 'Closing this PR due to 2 weeks of inactivity. Reopen or comment if it is still relevant.' + stale-pr-label: 'stale' + # leave issues untouched + days-before-issue-stale: -1 + days-before-issue-close: -1 + exempt-pr-labels: 'pinned,security,wip' \ No newline at end of file From 1defe7d72921ab12ad01139531cb98f71e7d3162 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 17 Jun 2026 14:55:17 -0600 Subject: [PATCH 064/160] ci: grant issues:read so actions/stale can enumerate PRs actions/stale fetches candidates through the issues API even for PR-only configs; without issues read access scheduled runs can fail in private/restricted-token repos before any PR is processed. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/stale-prs.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stale-prs.yml b/.github/workflows/stale-prs.yml index f2a9a3ffb..42dc48246 100644 --- a/.github/workflows/stale-prs.yml +++ b/.github/workflows/stale-prs.yml @@ -7,6 +7,7 @@ on: permissions: pull-requests: write # needed to comment, label, and close + issues: read # actions/stale enumerates PR candidates via the issues API jobs: stale: @@ -21,4 +22,4 @@ jobs: # leave issues untouched days-before-issue-stale: -1 days-before-issue-close: -1 - exempt-pr-labels: 'pinned,security,wip' \ No newline at end of file + exempt-pr-labels: 'pinned,security,wip' From 7cb5a3aeabb346eaf6b09cba4f157e592adb90ab Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:23:52 -0400 Subject: [PATCH 065/160] Add API routes to add and remove wells from projects. Supports the well edit panel in OcotilloUI (BDMS-879): POST and DELETE on /group/{group_id}/things/{thing_id} with 404/409 handling and audit on create. --- api/group.py | 62 ++++++++++++++++++++++---------------- services/group_helper.py | 64 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 26 deletions(-) diff --git a/api/group.py b/api/group.py index 962870c1e..963c95ba8 100644 --- a/api/group.py +++ b/api/group.py @@ -28,9 +28,11 @@ from schemas.group import UpdateGroup, CreateGroup, GroupResponse from services.crud_helper import model_patcher, model_deleter, model_adder from services.group_helper import ( + add_thing_to_group, get_well_counts_by_group_id, group_to_response, paginated_groups_getter, + remove_thing_from_group, ) from services.query_helper import simple_get_by_id @@ -49,21 +51,22 @@ def create_group( return model_adder(session, Group, group_data, user=user) -# @router.post( -# "/association", -# summary="Create a new group-thing association", -# status_code=status.HTTP_201_CREATED, -# ) -# def create_group_thing( -# group_location_data: CreateGroupThing, -# session: session_dependency, -# user: admin_dependency, -# ): -# """ -# Create a new group location association in the database. -# """ -# return adder(session, GroupThingAssociation, group_location_data, user=user) -# +@router.post( + "/{group_id}/things/{thing_id}", + summary="Add a thing to a group", + status_code=HTTP_201_CREATED, +) +def add_thing_to_group_route( + group_id: int, + thing_id: int, + session: session_dependency, + user: admin_dependency, +): + """ + Associate a thing (e.g. a water well) with a group (project). + Returns 409 if the association already exists. + """ + return add_thing_to_group(session, group_id, thing_id, user) # ============= Get ============================================= @@ -91,17 +94,6 @@ def get_group_by_id( return group_to_response(group, counts.get(group.id, 0)) -# @router.get( -# "/association/{association_id}", -# summary="Get group-thing association by ID", -# ) -# async def get_group_thing_by_id(association_id: int, session: session_dependency): -# """ -# Retrieve a group-thing association by ID from the database. -# """ -# return simple_get_by_id(session, GroupThingAssociation, association_id) - - # ============= Patch ============================================= @router.patch("/{group_id}", summary="Update a group by ID") def update_group( @@ -117,6 +109,24 @@ def update_group( # DELETE ======================================================================= +@router.delete( + "/{group_id}/things/{thing_id}", + summary="Remove a thing from a group", + status_code=HTTP_204_NO_CONTENT, +) +def remove_thing_from_group_route( + group_id: int, + thing_id: int, + session: session_dependency, + user: admin_dependency, +): + """ + Remove the association between a thing and a group. + Returns 404 if the association does not exist. + """ + remove_thing_from_group(session, group_id, thing_id) + + @router.delete( "/{group_id}", summary="Delete a group by ID", status_code=HTTP_204_NO_CONTENT ) diff --git a/services/group_helper.py b/services/group_helper.py index b81dd81c2..9d73333e7 100644 --- a/services/group_helper.py +++ b/services/group_helper.py @@ -15,13 +15,16 @@ # =============================================================================== from typing import Any +from fastapi import HTTPException from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import func, select from sqlalchemy.orm import Session +from starlette.status import HTTP_404_NOT_FOUND, HTTP_409_CONFLICT from db.group import Group, GroupThingAssociation from db.thing import Thing from schemas.group import GroupResponse +from services.audit_helper import audit_add from services.query_helper import order_sort_filter @@ -49,6 +52,67 @@ def group_to_response(group: Group, well_count: int = 0) -> GroupResponse: return response.model_copy(update={"well_count": well_count}) +def add_thing_to_group( + session: Session, group_id: int, thing_id: int, user: dict +) -> GroupThingAssociation: + group = session.get(Group, group_id) + if group is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"Group with ID {group_id} not found.", + ) + + thing = session.get(Thing, thing_id) + if thing is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"Thing with ID {thing_id} not found.", + ) + + existing = session.execute( + select(GroupThingAssociation).where( + GroupThingAssociation.group_id == group_id, + GroupThingAssociation.thing_id == thing_id, + ) + ).scalar_one_or_none() + + if existing is not None: + msg = f"Thing {thing_id} is already a member of group {group_id}." + raise HTTPException(status_code=HTTP_409_CONFLICT, detail=msg) + + assoc = GroupThingAssociation(group_id=group_id, thing_id=thing_id) + audit_add(user, assoc) + session.add(assoc) + session.commit() + session.refresh(assoc) + return assoc + + +def remove_thing_from_group( + session: Session, + group_id: int, + thing_id: int, +) -> None: + assoc = session.execute( + select(GroupThingAssociation).where( + GroupThingAssociation.group_id == group_id, + GroupThingAssociation.thing_id == thing_id, + ) + ).scalar_one_or_none() + + if assoc is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=( + f"No association found between group {group_id} " + f"and thing {thing_id}." + ), + ) + + session.delete(assoc) + session.commit() + + def paginated_groups_getter( session: Session, filter_: str | None = None, From bb1e6df3ed33c94482764a34a7acc178c70d7862 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:23:52 -0400 Subject: [PATCH 066/160] Add API routes to add and remove wells from projects. Supports the well edit panel in OcotilloUI (BDMS-879): POST and DELETE on /group/{group_id}/things/{thing_id} with 404/409 handling and audit on create. --- api/group.py | 62 ++++++++++++++++++++++---------------- services/group_helper.py | 64 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 26 deletions(-) diff --git a/api/group.py b/api/group.py index 962870c1e..963c95ba8 100644 --- a/api/group.py +++ b/api/group.py @@ -28,9 +28,11 @@ from schemas.group import UpdateGroup, CreateGroup, GroupResponse from services.crud_helper import model_patcher, model_deleter, model_adder from services.group_helper import ( + add_thing_to_group, get_well_counts_by_group_id, group_to_response, paginated_groups_getter, + remove_thing_from_group, ) from services.query_helper import simple_get_by_id @@ -49,21 +51,22 @@ def create_group( return model_adder(session, Group, group_data, user=user) -# @router.post( -# "/association", -# summary="Create a new group-thing association", -# status_code=status.HTTP_201_CREATED, -# ) -# def create_group_thing( -# group_location_data: CreateGroupThing, -# session: session_dependency, -# user: admin_dependency, -# ): -# """ -# Create a new group location association in the database. -# """ -# return adder(session, GroupThingAssociation, group_location_data, user=user) -# +@router.post( + "/{group_id}/things/{thing_id}", + summary="Add a thing to a group", + status_code=HTTP_201_CREATED, +) +def add_thing_to_group_route( + group_id: int, + thing_id: int, + session: session_dependency, + user: admin_dependency, +): + """ + Associate a thing (e.g. a water well) with a group (project). + Returns 409 if the association already exists. + """ + return add_thing_to_group(session, group_id, thing_id, user) # ============= Get ============================================= @@ -91,17 +94,6 @@ def get_group_by_id( return group_to_response(group, counts.get(group.id, 0)) -# @router.get( -# "/association/{association_id}", -# summary="Get group-thing association by ID", -# ) -# async def get_group_thing_by_id(association_id: int, session: session_dependency): -# """ -# Retrieve a group-thing association by ID from the database. -# """ -# return simple_get_by_id(session, GroupThingAssociation, association_id) - - # ============= Patch ============================================= @router.patch("/{group_id}", summary="Update a group by ID") def update_group( @@ -117,6 +109,24 @@ def update_group( # DELETE ======================================================================= +@router.delete( + "/{group_id}/things/{thing_id}", + summary="Remove a thing from a group", + status_code=HTTP_204_NO_CONTENT, +) +def remove_thing_from_group_route( + group_id: int, + thing_id: int, + session: session_dependency, + user: admin_dependency, +): + """ + Remove the association between a thing and a group. + Returns 404 if the association does not exist. + """ + remove_thing_from_group(session, group_id, thing_id) + + @router.delete( "/{group_id}", summary="Delete a group by ID", status_code=HTTP_204_NO_CONTENT ) diff --git a/services/group_helper.py b/services/group_helper.py index b81dd81c2..9d73333e7 100644 --- a/services/group_helper.py +++ b/services/group_helper.py @@ -15,13 +15,16 @@ # =============================================================================== from typing import Any +from fastapi import HTTPException from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import func, select from sqlalchemy.orm import Session +from starlette.status import HTTP_404_NOT_FOUND, HTTP_409_CONFLICT from db.group import Group, GroupThingAssociation from db.thing import Thing from schemas.group import GroupResponse +from services.audit_helper import audit_add from services.query_helper import order_sort_filter @@ -49,6 +52,67 @@ def group_to_response(group: Group, well_count: int = 0) -> GroupResponse: return response.model_copy(update={"well_count": well_count}) +def add_thing_to_group( + session: Session, group_id: int, thing_id: int, user: dict +) -> GroupThingAssociation: + group = session.get(Group, group_id) + if group is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"Group with ID {group_id} not found.", + ) + + thing = session.get(Thing, thing_id) + if thing is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"Thing with ID {thing_id} not found.", + ) + + existing = session.execute( + select(GroupThingAssociation).where( + GroupThingAssociation.group_id == group_id, + GroupThingAssociation.thing_id == thing_id, + ) + ).scalar_one_or_none() + + if existing is not None: + msg = f"Thing {thing_id} is already a member of group {group_id}." + raise HTTPException(status_code=HTTP_409_CONFLICT, detail=msg) + + assoc = GroupThingAssociation(group_id=group_id, thing_id=thing_id) + audit_add(user, assoc) + session.add(assoc) + session.commit() + session.refresh(assoc) + return assoc + + +def remove_thing_from_group( + session: Session, + group_id: int, + thing_id: int, +) -> None: + assoc = session.execute( + select(GroupThingAssociation).where( + GroupThingAssociation.group_id == group_id, + GroupThingAssociation.thing_id == thing_id, + ) + ).scalar_one_or_none() + + if assoc is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=( + f"No association found between group {group_id} " + f"and thing {thing_id}." + ), + ) + + session.delete(assoc) + session.commit() + + def paginated_groups_getter( session: Session, filter_: str | None = None, From 31f1bcc42219772ac40d12598875eeb865c6d49e Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 067/160] Add shared helper for Slack edit notifications (BDMS-921). EditEvent model, Block Kit payload builder, and best-effort background webhook post when SLACK_EDITS_WEBHOOK_URL is set. --- services/edit_notification_helper.py | 217 +++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 services/edit_notification_helper.py diff --git a/services/edit_notification_helper.py b/services/edit_notification_helper.py new file mode 100644 index 000000000..3826cd1c1 --- /dev/null +++ b/services/edit_notification_helper.py @@ -0,0 +1,217 @@ +# =============================================================================== +# Copyright 2025 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 __future__ import annotations + +import logging +import os +import threading +from datetime import datetime, timezone +from typing import Any, Literal + +import httpx +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +EditAction = Literal[ + "attachment_uploaded", + "project_added", + "project_removed", + "record_updated", + "record_created", + "record_deleted", +] + +NOTIFY_RESOURCE_TYPES = frozenset( + { + "well", + "spring", + "thing", + "contact", + "asset", + "group", + "location", + "sensor", + "sample", + } +) + +ACTION_HEADINGS: dict[EditAction, str] = { + "attachment_uploaded": "Attachment uploaded", + "project_added": "Project added", + "project_removed": "Project removed", + "record_updated": "Record updated", + "record_created": "Record created", + "record_deleted": "Record deleted", +} + +RESOURCE_UI_PATHS: dict[str, str] = { + "well": "ocotillo/well/show/{resource_id}", + "spring": "ocotillo/spring/show/{resource_id}", + "thing": "ocotillo/well/show/{resource_id}", + "contact": "ocotillo/contact/show/{resource_id}", + "group": "ocotillo/group/show/{resource_id}", + "asset": "ocotillo/asset/show/{resource_id}", + "location": "ocotillo/location/show/{resource_id}", + "sensor": "ocotillo/sensor/show/{resource_id}", + "sample": "ocotillo/sample/show/{resource_id}", +} + + +class EditEvent(BaseModel): + action: EditAction + resource_type: str + resource_id: int | str + resource_label: str + summary: str + field_changes: dict[str, dict[str, Any]] | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +def format_file_size(size_bytes: int) -> str: + if size_bytes < 1024: + return f"{size_bytes} B" + if size_bytes < 1024**2: + return f"{size_bytes / 1024:.1f} KB" + return f"{size_bytes / (1024**2):.1f} MB" + + +def environment_label(environment: str | None = None) -> str: + raw = environment or os.environ.get("ENVIRONMENT", "unknown") + env = raw.strip().lower() + if env == "production": + return "PRODUCTION" + if env == "staging": + return "STAGING" + return env.upper() or "UNKNOWN" + + +def build_record_url(resource_type: str, resource_id: int | str) -> str | None: + base = (os.environ.get("OCOTILLO_UI_BASE_URL") or "").strip().rstrip("/") + if not base: + return None + + path_template = RESOURCE_UI_PATHS.get(resource_type) + if not path_template: + return None + + return f"{base}/{path_template.format(resource_id=resource_id)}" + + +def format_field_changes( + field_changes: dict[str, dict[str, Any]] | None, +) -> str: + if not field_changes: + return "" + + lines: list[str] = [] + for field, change in field_changes.items(): + before = _format_display_value(change.get("before")) + after = _format_display_value(change.get("after")) + lines.append(f"{field}: {before} → {after}") + return "\n".join(lines) + + +def build_slack_payload( + event: EditEvent, + user: dict[str, Any], + environment: str | None = None, +) -> dict[str, Any]: + env_label = environment_label(environment) + heading_action = ACTION_HEADINGS.get(event.action, event.action) + header = f"[{env_label}] {heading_action} — {event.resource_label}" + + actor_name = ( + user.get("name") or user.get("preferred_username") or "Unknown" + ) + actor_email = user.get("email") + who = actor_name if not actor_email else f"{actor_name} ({actor_email})" + when = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + fields: list[dict[str, str]] = [ + {"type": "mrkdwn", "text": f"*Who:*\n{who}"}, + {"type": "mrkdwn", "text": f"*When:*\n{when}"}, + {"type": "mrkdwn", "text": f"*What:*\n{event.summary}"}, + ] + + diff_text = format_field_changes(event.field_changes) + if diff_text: + fields.append({"type": "mrkdwn", "text": f"*Changes:*\n{diff_text}"}) + + header_block = { + "type": "header", + "text": {"type": "plain_text", "text": header[:150]}, + } + blocks: list[dict[str, Any]] = [ + header_block, + {"type": "section", "fields": fields[:10]}, + ] + + record_url = build_record_url(event.resource_type, event.resource_id) + if record_url: + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"<{record_url}|View in Ocotillo →>", + }, + } + ) + + return {"text": header, "blocks": blocks} + + +def notify_edit_event(user: Any, event: EditEvent) -> None: + if not isinstance(user, dict): + return + + webhook = os.environ.get("SLACK_EDITS_WEBHOOK_URL") + if not webhook: + return + + if event.resource_type not in NOTIFY_RESOURCE_TYPES: + return + + payload = build_slack_payload(event, user) + _post_slack_async(webhook, payload) + + +def _post_slack_async(webhook_url: str, payload: dict[str, Any]) -> None: + def _send() -> None: + try: + httpx.post(webhook_url, json=payload, timeout=10.0) + except Exception: + logger.warning( + "Slack edit notification failed", + exc_info=True, + ) + + threading.Thread(target=_send, daemon=True).start() + + +def _format_display_value(value: Any) -> str: + if value is None: + return "N/A" + if isinstance(value, str) and not value.strip(): + return "N/A" + text = str(value) + if len(text) > 200: + return f"{text[:197]}..." + return text + + +# ============= EOF ============================================= From bf052983f2a615e9449212eb33b9513061e595ae Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 068/160] Document SLACK_EDITS_WEBHOOK_URL and OCOTILLO_UI_BASE_URL in .env.example. --- .env.example | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.env.example b/.env.example index 1645fa31c..e6ead732f 100644 --- a/.env.example +++ b/.env.example @@ -77,3 +77,11 @@ JIRA_API_TOKEN=your_jira_api_token JIRA_DEFAULT_PROJECT=BDMS # Optional — Slack notifications are skipped if this is blank SLACK_FEEDBACK_WEBHOOK_URL= + +# Edit notifications (Slack) — POST/PATCH/DELETE mutations and uploads +# Optional — notifications are skipped if this is blank +SLACK_EDITS_WEBHOOK_URL= +# Base URL for deep links in Slack messages (no trailing slash) +# Staging: https://ocotillo-staging.newmexicowaterdata.org +# Production: https://ocotillo.newmexicowaterdata.org +OCOTILLO_UI_BASE_URL= From 58d8dd365d1228114d2c849c01406c92dec54028 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 069/160] Pass edit-notification env vars through the App Engine deploy template. --- .github/app.template.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/app.template.yaml b/.github/app.template.yaml index d3eb23ab0..3abdacb68 100644 --- a/.github/app.template.yaml +++ b/.github/app.template.yaml @@ -45,3 +45,6 @@ env_variables: JIRA_DEFAULT_PROJECT: "${JIRA_DEFAULT_PROJECT}" SLACK_FEEDBACK_WEBHOOK_URL: |- ${SLACK_FEEDBACK_WEBHOOK_URL} + SLACK_EDITS_WEBHOOK_URL: |- + ${SLACK_EDITS_WEBHOOK_URL} + OCOTILLO_UI_BASE_URL: "${OCOTILLO_UI_BASE_URL}" From 1b1def2c160a2d37d4337b1c26a4f40dd11fe733 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 070/160] Notify Slack when a well is added to or removed from a project. --- services/group_helper.py | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/services/group_helper.py b/services/group_helper.py index 9d73333e7..8fa501406 100644 --- a/services/group_helper.py +++ b/services/group_helper.py @@ -25,9 +25,18 @@ from db.thing import Thing from schemas.group import GroupResponse from services.audit_helper import audit_add +from services.edit_notification_helper import EditEvent, notify_edit_event from services.query_helper import order_sort_filter +def _thing_resource_type(thing: Thing) -> str: + if thing.thing_type == "water well": + return "well" + if thing.thing_type == "spring": + return "spring" + return "thing" + + def get_well_counts_by_group_id( session: Session, group_ids: list[int] ) -> dict[int, int]: @@ -85,6 +94,20 @@ def add_thing_to_group( session.add(assoc) session.commit() session.refresh(assoc) + + thing_label = thing.name or f"Thing {thing_id}" + group_name = group.name or f"Group {group_id}" + notify_edit_event( + user, + EditEvent( + action="project_added", + resource_type=_thing_resource_type(thing), + resource_id=thing_id, + resource_label=thing_label, + summary=f'Added {thing_label} to project "{group_name}"', + metadata={"group_id": group_id, "group_name": group_name}, + ), + ) return assoc @@ -92,7 +115,11 @@ def remove_thing_from_group( session: Session, group_id: int, thing_id: int, + user: dict | None = None, ) -> None: + group = session.get(Group, group_id) + thing = session.get(Thing, thing_id) + assoc = session.execute( select(GroupThingAssociation).where( GroupThingAssociation.group_id == group_id, @@ -112,6 +139,21 @@ def remove_thing_from_group( session.delete(assoc) session.commit() + if user and thing is not None: + thing_label = thing.name or f"Thing {thing_id}" + group_name = (group.name if group else None) or f"Group {group_id}" + notify_edit_event( + user, + EditEvent( + action="project_removed", + resource_type=_thing_resource_type(thing), + resource_id=thing_id, + resource_label=thing_label, + summary=f'Removed {thing_label} from project "{group_name}"', + metadata={"group_id": group_id, "group_name": group_name}, + ), + ) + def paginated_groups_getter( session: Session, From 7ca550ff5e1b4387ede8b9d6fba89809497eb854 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 071/160] Pass the authenticated user into group delete and thing-removal routes for edit notifications. --- api/group.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/group.py b/api/group.py index 963c95ba8..98ee3abc2 100644 --- a/api/group.py +++ b/api/group.py @@ -124,14 +124,14 @@ def remove_thing_from_group_route( Remove the association between a thing and a group. Returns 404 if the association does not exist. """ - remove_thing_from_group(session, group_id, thing_id) + remove_thing_from_group(session, group_id, thing_id, user=user) @router.delete( "/{group_id}", summary="Delete a group by ID", status_code=HTTP_204_NO_CONTENT ) def delete_group(user: admin_dependency, group_id: int, session: session_dependency): - return model_deleter(session, Group, group_id) + return model_deleter(session, Group, group_id, user=user) # ============= EOF ============================================= From eaf3e44c56e5fd7ab4ad34af3e6efb9338988409 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 072/160] Notify Slack after a new asset upload on upload-and-record, not on duplicates. --- api/asset.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/api/asset.py b/api/asset.py index f56f3f3d6..32fa88580 100644 --- a/api/asset.py +++ b/api/asset.py @@ -41,6 +41,11 @@ from schemas.asset import AssetResponse, CreateAsset, UpdateAsset from services.audit_helper import audit_add from services.crud_helper import model_patcher, model_deleter +from services.edit_notification_helper import ( + EditEvent, + format_file_size, + notify_edit_event, +) from services.env import get_bool_env from services.exceptions_helper import PydanticStyleException from services.query_helper import simple_get_by_id @@ -345,6 +350,29 @@ async def upload_and_record_asset( raise session.refresh(asset) + + thing_label = thing.name or f"Thing {thing_id}" + file_name = asset.name or file.filename or "attachment" + + notify_edit_event( + user, + EditEvent( + action="attachment_uploaded", + resource_type="well" if thing.thing_type == "water well" else "thing", + resource_id=thing_id, + resource_label=thing_label, + summary=( + f"Uploaded {file_name} ({asset.mime_type}, " + f"{format_file_size(asset.size or file_size)}) to {thing_label}" + ), + metadata={ + "file_name": file_name, + "mime_type": asset.mime_type, + "size": asset.size or file_size, + "asset_id": asset.id, + }, + ), + ) return asset From ef49b0fd01664d650e59df3220140c8c149d822f Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 073/160] Emit Slack edit notifications from generic create, update, and delete helpers. --- services/crud_helper.py | 131 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/services/crud_helper.py b/services/crud_helper.py index 01eaeb254..dcc0e087f 100644 --- a/services/crud_helper.py +++ b/services/crud_helper.py @@ -13,14 +13,26 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +from typing import Any + from fastapi import Response from pydantic import BaseModel -from sqlalchemy.orm import Session, DeclarativeBase +from sqlalchemy.orm import DeclarativeBase, Session from starlette.status import HTTP_204_NO_CONTENT from db.notes import NotesMixin +from services.edit_notification_helper import EditEvent, notify_edit_event from services.query_helper import simple_get_by_id +TABLE_RESOURCE_TYPES: dict[str, str] = { + "contact": "contact", + "group": "group", + "asset": "asset", + "location": "location", + "sensor": "sensor", + "sample": "sample", +} + def model_adder(session, table, model, user=None, **kwargs): """ @@ -53,6 +65,22 @@ def model_adder(session, table, model, user=None, **kwargs): session.commit() session.refresh(obj) + + if user: + resource_type = _resource_type_for_item(table, obj) + if resource_type: + label = _resource_label(obj) + notify_edit_event( + user, + EditEvent( + action="record_created", + resource_type=resource_type, + resource_id=obj.id, + resource_label=label, + summary=f"Created {resource_type} {label}", + ), + ) + return obj @@ -72,8 +100,10 @@ def model_patcher( exclude_unset ensures that fields that are not set in the payload do not update record fields to None """ + updates = payload.model_dump(exclude_unset=True) + before = _snapshot_field_values(item, updates.keys()) - for key, value in payload.model_dump(exclude_unset=True).items(): + for key, value in updates.items(): if isinstance(item, NotesMixin) and key == "notes": # delete all notes and re-add for note in item.notes: @@ -91,15 +121,110 @@ def model_patcher( session.commit() session.refresh(item) + + if user: + resource_type = _resource_type_for_item(model, item) + if resource_type: + label = _resource_label(item) + field_changes = _compute_field_changes( + before, item, updates.keys() + ) + summary = f"Updated {resource_type} {label}" + notify_edit_event( + user, + EditEvent( + action="record_updated", + resource_type=resource_type, + resource_id=item.id, + resource_label=label, + summary=summary, + field_changes=field_changes or None, + ), + ) + return item -def model_deleter(session: Session, model: DeclarativeBase, item_id: int): +def model_deleter( + session: Session, + model: DeclarativeBase, + item_id: int, + user: dict | None = None, +): # simple_get_by_id raises HTTP_404_NOT_FOUND if the item is not found item = simple_get_by_id(session, model, item_id) + resource_type = _resource_type_for_item(model, item) + label = _resource_label(item) + item_id_value = item.id + session.delete(item) session.commit() + + if user and resource_type: + notify_edit_event( + user, + EditEvent( + action="record_deleted", + resource_type=resource_type, + resource_id=item_id_value, + resource_label=label, + summary=f"Deleted {resource_type} {label}", + ), + ) + return Response(status_code=HTTP_204_NO_CONTENT) +def _resource_type_for_item(model: type, item: Any) -> str | None: + table = getattr(model, "__tablename__", None) + if table == "thing": + thing_type = getattr(item, "thing_type", None) + if thing_type == "water well": + return "well" + if thing_type == "spring": + return "spring" + return "thing" + return TABLE_RESOURCE_TYPES.get(table or "") + + +def _resource_label(item: Any) -> str: + for attr in ("name", "label", "title", "site_name"): + value = getattr(item, attr, None) + if value: + return str(value) + item_id = getattr(item, "id", None) + return f"ID {item_id}" if item_id is not None else "record" + + +def _snapshot_field_values(item: Any, keys: Any) -> dict[str, Any]: + return { + key: _serialize_field_value(getattr(item, key, None)) for key in keys + } + + +def _compute_field_changes( + before: dict[str, Any], + item: Any, + keys: Any, +) -> dict[str, dict[str, Any]]: + changes: dict[str, dict[str, Any]] = {} + for key in keys: + after_value = _serialize_field_value(getattr(item, key, None)) + if before.get(key) != after_value: + changes[key] = {"before": before.get(key), "after": after_value} + return changes + + +def _serialize_field_value(value: Any) -> Any: + if value is None: + return None + if hasattr(value, "isoformat"): + return value.isoformat() + if hasattr(value, "wkt"): + return value.wkt + if isinstance(value, (list, dict)): + return value + return value + + # ============= EOF ============================================= From c294d174f993871ae1c786478b8aed2ca6153eaf Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 074/160] Add unit tests for edit notification payload building and notify behavior. --- tests/test_edit_notification_helper.py | 145 +++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/test_edit_notification_helper.py diff --git a/tests/test_edit_notification_helper.py b/tests/test_edit_notification_helper.py new file mode 100644 index 000000000..2a8d45b4c --- /dev/null +++ b/tests/test_edit_notification_helper.py @@ -0,0 +1,145 @@ +import pytest + +from services.edit_notification_helper import ( + EditEvent, + build_record_url, + build_slack_payload, + environment_label, + format_field_changes, + format_file_size, + notify_edit_event, +) + + +@pytest.fixture +def slack_capture(monkeypatch): + calls: list[tuple[str, dict]] = [] + + def _capture(webhook_url: str, payload: dict) -> None: + calls.append((webhook_url, payload)) + + monkeypatch.setenv("SLACK_EDITS_WEBHOOK_URL", "https://hooks.slack.test/edit") + monkeypatch.setenv("OCOTILLO_UI_BASE_URL", "https://ocotillo.example.org") + monkeypatch.setattr( + "services.edit_notification_helper._post_slack_async", + _capture, + ) + return calls + + +def test_environment_label(): + assert environment_label("staging") == "STAGING" + assert environment_label("production") == "PRODUCTION" + assert environment_label("dev") == "DEV" + + +def test_format_file_size(): + assert format_file_size(512) == "512 B" + assert format_file_size(2048) == "2.0 KB" + assert format_file_size(2 * 1024 * 1024) == "2.0 MB" + + +def test_build_record_url(monkeypatch): + monkeypatch.setenv("OCOTILLO_UI_BASE_URL", "https://ocotillo.example.org") + assert ( + build_record_url("well", 42) + == "https://ocotillo.example.org/ocotillo/well/show/42" + ) + assert build_record_url("unknown", 1) is None + + +def test_build_slack_payload_includes_environment_and_diffs(monkeypatch): + monkeypatch.setenv("OCOTILLO_UI_BASE_URL", "https://ocotillo.example.org") + event = EditEvent( + action="record_updated", + resource_type="contact", + resource_id=7, + resource_label="Jane Doe", + summary="Updated contact Jane Doe", + field_changes={ + "phone": {"before": "505-555-1234", "after": "505-555-5678"}, + }, + ) + user = {"name": "Jeremy Zilar", "email": "jeremy@example.org"} + + payload = build_slack_payload(event, user, environment="staging") + header = payload["blocks"][0]["text"]["text"] + + assert header.startswith("[STAGING] Record updated — Jane Doe") + assert payload["blocks"][1]["fields"][0]["text"].startswith("*Who:*") + assert "505-555-1234" in payload["blocks"][1]["fields"][3]["text"] + assert "View in Ocotillo" in payload["blocks"][2]["text"]["text"] + + +def test_format_field_changes_empty(): + assert format_field_changes(None) == "" + assert format_field_changes({}) == "" + + +def test_build_slack_payload_attachment_upload(): + event = EditEvent( + action="attachment_uploaded", + resource_type="well", + resource_id=28251, + resource_label="NM-28251", + summary=( + "Uploaded construction_log.pdf (application/pdf, 1.2 MB) " "to NM-28251" + ), + ) + payload = build_slack_payload( + event, + {"name": "Tyler Smith", "email": "tyler@example.org"}, + environment="production", + ) + + assert payload["blocks"][0]["text"]["text"].startswith( + "[PRODUCTION] Attachment uploaded — NM-28251" + ) + + +def test_notify_edit_event_skips_without_webhook(monkeypatch, slack_capture): + monkeypatch.delenv("SLACK_EDITS_WEBHOOK_URL", raising=False) + notify_edit_event( + {"name": "Test User"}, + EditEvent( + action="project_added", + resource_type="well", + resource_id=1, + resource_label="NM-1", + summary='Added NM-1 to project "Demo"', + ), + ) + assert slack_capture == [] + + +def test_notify_edit_event_skips_non_dict_user(slack_capture): + notify_edit_event( + True, + EditEvent( + action="project_added", + resource_type="well", + resource_id=1, + resource_label="NM-1", + summary='Added NM-1 to project "Demo"', + ), + ) + assert slack_capture == [] + + +def test_notify_edit_event_posts_payload(slack_capture): + notify_edit_event( + {"name": "Test User", "email": "test@example.org"}, + EditEvent( + action="project_removed", + resource_type="well", + resource_id=99, + resource_label="NM-99", + summary='Removed NM-99 from project "Demo"', + ), + ) + + assert len(slack_capture) == 1 + webhook, payload = slack_capture[0] + assert webhook == "https://hooks.slack.test/edit" + assert "project_removed" not in payload["text"] + assert "NM-99" in payload["text"] From ebfb11cc1edee49b99b0fec62763d2ae54fe1bb3 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 075/160] Add group thing association route tests and Slack notification coverage. --- tests/test_group.py | 96 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/tests/test_group.py b/tests/test_group.py index de4c6672a..a973c9877 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -230,3 +230,99 @@ def test_delete_group_404_not_found(second_group): assert response.status_code == 404 data = response.json() assert data["detail"] == f"Group with ID {bad_id} not found." + + +# GROUP-THING association tests ================================================ + + +def test_add_thing_to_group_route(spring_thing): + payload = { + "release_status": "private", + "name": "Slack Notify Test Group", + "description": "Temporary group for association test.", + } + create_response = client.post("/group", json=payload) + assert create_response.status_code == 201 + group_id = create_response.json()["id"] + + response = client.post(f"/group/{group_id}/things/{spring_thing.id}") + assert response.status_code == 201 + data = response.json() + assert data["group_id"] == group_id + assert data["thing_id"] == spring_thing.id + + cleanup_post_test(GroupThingAssociation, data["id"]) + cleanup_post_test(Group, group_id) + + +def test_add_thing_to_group_route_409_duplicate(group, water_well_thing): + response = client.post(f"/group/{group.id}/things/{water_well_thing.id}") + assert response.status_code == 409 + + +def test_remove_thing_from_group_route(group, water_well_thing): + response = client.delete(f"/group/{group.id}/things/{water_well_thing.id}") + assert response.status_code == 204 + + # restore association for other tests using this fixture + with session_ctx() as session: + session.add( + GroupThingAssociation(group_id=group.id, thing_id=water_well_thing.id) + ) + session.commit() + + +def test_add_thing_to_group_notifies_slack(spring_thing, monkeypatch): + calls: list[tuple[str, dict]] = [] + + def _capture(webhook_url: str, payload: dict) -> None: + calls.append((webhook_url, payload)) + + monkeypatch.setenv("SLACK_EDITS_WEBHOOK_URL", "https://hooks.slack.test/edit") + monkeypatch.setattr( + "services.edit_notification_helper._post_slack_async", + _capture, + ) + + payload = { + "release_status": "private", + "name": "Slack Association Group", + "description": "Temporary group for Slack test.", + } + create_response = client.post("/group", json=payload) + group_id = create_response.json()["id"] + + response = client.post(f"/group/{group_id}/things/{spring_thing.id}") + assert response.status_code == 201 + assoc_id = response.json()["id"] + + project_calls = [call for call in calls if "Project added" in call[1]["text"]] + assert len(project_calls) == 1 + assert spring_thing.name in project_calls[0][1]["text"] + + cleanup_post_test(GroupThingAssociation, assoc_id) + cleanup_post_test(Group, group_id) + + +def test_remove_thing_from_group_notifies_slack(group, water_well_thing, monkeypatch): + calls: list[tuple[str, dict]] = [] + + def _capture(webhook_url: str, payload: dict) -> None: + calls.append((webhook_url, payload)) + + monkeypatch.setenv("SLACK_EDITS_WEBHOOK_URL", "https://hooks.slack.test/edit") + monkeypatch.setattr( + "services.edit_notification_helper._post_slack_async", + _capture, + ) + + response = client.delete(f"/group/{group.id}/things/{water_well_thing.id}") + assert response.status_code == 204 + assert len(calls) == 1 + assert water_well_thing.name in calls[0][1]["text"] + + with session_ctx() as session: + session.add( + GroupThingAssociation(group_id=group.id, thing_id=water_well_thing.id) + ) + session.commit() From 5cd7f75c71961839f494f8b5cc331414465b61b3 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 076/160] Add upload-and-record Slack notification tests, including duplicate silence. --- tests/test_asset.py | 70 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/test_asset.py b/tests/test_asset.py index d7ee02893..533cd560b 100644 --- a/tests/test_asset.py +++ b/tests/test_asset.py @@ -452,6 +452,76 @@ def test_upload_and_record_asset_duplicate_returns_existing(water_well_thing): cleanup_post_test(Asset, first_id) +def test_upload_and_record_asset_notifies_slack(water_well_thing, monkeypatch): + calls: list[tuple[str, dict]] = [] + + def _capture(webhook_url: str, payload: dict) -> None: + calls.append((webhook_url, payload)) + + monkeypatch.setenv("SLACK_EDITS_WEBHOOK_URL", "https://hooks.slack.test/edit") + monkeypatch.setattr( + "services.edit_notification_helper._post_slack_async", + _capture, + ) + + path = "tests/data/riochama.png" + with open(path, "rb") as f: + response = client.post( + "/asset/upload-and-record", + data={"thing_id": water_well_thing.id, "label": "Slack test photo"}, + files={"file": ("slack-test.png", f, "image/png")}, + ) + + assert response.status_code == 201 + assert len(calls) == 1 + payload = calls[0][1] + assert water_well_thing.name in payload["text"] + what_field = next( + field + for field in payload["blocks"][1]["fields"] + if field["text"].startswith("*What:*") + ) + assert "slack-test.png" in what_field["text"] + + cleanup_post_test(Asset, response.json()["id"]) + + +def test_upload_and_record_asset_duplicate_does_not_notify_slack( + water_well_thing, monkeypatch +): + calls: list[tuple[str, dict]] = [] + + def _capture(webhook_url: str, payload: dict) -> None: + calls.append((webhook_url, payload)) + + monkeypatch.setenv("SLACK_EDITS_WEBHOOK_URL", "https://hooks.slack.test/edit") + monkeypatch.setattr( + "services.edit_notification_helper._post_slack_async", + _capture, + ) + + path = "tests/data/riochama.png" + with open(path, "rb") as f: + first = client.post( + "/asset/upload-and-record", + data={"thing_id": water_well_thing.id}, + files={"file": ("riochama.png", f, "image/png")}, + ) + assert first.status_code == 201 + assert len(calls) == 1 + + with open(path, "rb") as f: + second = client.post( + "/asset/upload-and-record", + data={"thing_id": water_well_thing.id}, + files={"file": ("riochama.png", f, "image/png")}, + ) + assert second.status_code == 201 + assert len(calls) == 1 + + cleanup_post_test(Asset, first.json()["id"]) + + def test_upload_and_record_asset_bad_thing_id(): """ Providing a thing_id that does not exist must return 409 Conflict. From 23dd102bab451098a3c32f75697ddff7f7ac2c48 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 077/160] Wire edit-notification secrets and UI base URL into staging deploy workflow. --- .github/workflows/CD_staging.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index e55c6f2a4..8415c34e1 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -47,6 +47,7 @@ jobs: jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email 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 - name: Run Alembic migrations on staging database env: @@ -102,6 +103,8 @@ jobs: JIRA_API_TOKEN: "${{ steps.feedback-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 }}" + OCOTILLO_UI_BASE_URL: "${{ vars.OCOTILLO_UI_BASE_URL || 'https://ocotillo-staging.newmexicowaterdata.org' }}" run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api-staging" From cdb6a01324c137fac0269ab5cb0887e11514c595 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 078/160] Wire edit-notification secrets and UI base URL into production deploy workflow. --- .github/workflows/CD_production.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 1ade7f251..e7a3fba87 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -82,6 +82,7 @@ jobs: jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email 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 - name: Run Alembic migrations on production database env: @@ -142,6 +143,8 @@ jobs: JIRA_API_TOKEN: "${{ steps.feedback-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 }}" + OCOTILLO_UI_BASE_URL: "${{ vars.OCOTILLO_UI_BASE_URL || 'https://ocotillo.newmexicowaterdata.org' }}" run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api" From 6ad76175e848029041faa4aef0f7ae20506a8544 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 079/160] Wire edit-notification secrets and UI base URL into testing deploy workflow. --- .github/workflows/CD_testing.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index 64e15443e..32e1e34c1 100644 --- a/.github/workflows/CD_testing.yml +++ b/.github/workflows/CD_testing.yml @@ -47,6 +47,7 @@ jobs: jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email 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 - name: Run Alembic migrations on staging database env: @@ -102,6 +103,8 @@ jobs: JIRA_API_TOKEN: "${{ steps.feedback-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 }}" + OCOTILLO_UI_BASE_URL: "${{ vars.OCOTILLO_UI_BASE_URL || 'https://ocotillo-staging.newmexicowaterdata.org' }}" run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api-testing" From 6b9e9c6b803267a965d4b0ae77f1b13487281e94 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 080/160] Document how edit notifications will fold into Epic 6 activity logging. --- docs/edit-notifications-and-activity-log.md | 77 +++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/edit-notifications-and-activity-log.md diff --git a/docs/edit-notifications-and-activity-log.md b/docs/edit-notifications-and-activity-log.md new file mode 100644 index 000000000..522c7ff3d --- /dev/null +++ b/docs/edit-notifications-and-activity-log.md @@ -0,0 +1,77 @@ +# Edit notifications and the activity log (Epic 6) + +BDMS-921 adds Slack notifications when Ocotillo data is edited. Epic 6 (activity log) will persist the same events for in-app history. This document describes how the two fit together. + +## Current state (BDMS-921) + +Mutations in the OcotilloAPI service layer call `notify_edit_event(user, event)` from `services/edit_notification_helper.py`. + +- `EditEvent` carries action, resource type/id/label, summary, optional field diffs, and metadata. +- When `SLACK_EDITS_WEBHOOK_URL` is set, the helper posts a Block Kit message to Slack in a background thread. +- When the webhook is unset (local dev), or `user` is not a dict (auth disabled in tests), notification is a no-op. +- Failures are logged and never fail the HTTP request. + +Wired today: + +| Action | Where | +|--------|--------| +| `attachment_uploaded` | `api/asset.py` `upload_and_record_asset` (new uploads only) | +| `project_added` / `project_removed` | `services/group_helper.py` | +| `record_created` / `record_updated` / `record_deleted` | `services/crud_helper.py` | + +## Future state (Epic 6.1) + +Epic 6 introduces an `ActivityLog` table and a service helper, roughly: + +```python +def log_activity( + session, + actor, + action, + resource_type, + resource_id, + *, + resource_label=None, + field_changes=None, + metadata=None, +): + # persist ActivityLog row (not built yet) + notify_edit_event( + actor, + EditEvent( + action=action, + resource_type=resource_type, + resource_id=resource_id, + resource_label=resource_label or f"ID {resource_id}", + summary=_activity_summary(...), + field_changes=field_changes, + metadata=metadata or {}, + ), + ) +``` + +### Migration path + +1. **Keep `EditEvent` as the shared event shape** so Slack payloads and the activity log UI read the same fields (`actor`, `action`, `resource_*`, `field_changes`, `metadata`). +2. **Move call sites from `notify_edit_event` to `log_activity`** as Epic 6.1 lands. `log_activity` writes to PostgreSQL first, then calls `notify_edit_event` as a side effect. +3. **Retire direct `notify_edit_event` calls** in route handlers and one-off helpers once those paths go through `log_activity`. +4. **Map action names** between Slack labels and Epic 6 enums where they differ (e.g. `project_added` → activity log `update` with metadata describing the project change). + +### Field diffs + +`model_patcher` already computes `{field: {before, after}}` for Slack. Epic 6 stores the same JSON on `ActivityLog.field_changes`. No second diff format is needed. + +### Exclusions (unchanged) + +- `POST /feedback` keeps its own Slack webhook. +- Transfer scripts, bulk imports, and non-user mutations should not call `log_activity` or `notify_edit_event`. + +## Environment variables + +| Variable | Purpose | +|----------|---------| +| `SLACK_EDITS_WEBHOOK_URL` | Incoming webhook for edit notifications (Secret Manager in deployed envs) | +| `OCOTILLO_UI_BASE_URL` | UI origin for deep links in Slack messages | +| `ENVIRONMENT` | `staging` or `production`; prefixed in Slack headers | + +See `.env.example` for local defaults. From cf3f5bd991f24ae41d684bc899eaddd214ed6323 Mon Sep 17 00:00:00 2001 From: jeremyzilar <395641+jeremyzilar@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:53:37 +0000 Subject: [PATCH 081/160] Formatting changes --- services/crud_helper.py | 8 ++------ services/edit_notification_helper.py | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/services/crud_helper.py b/services/crud_helper.py index dcc0e087f..49d1c9146 100644 --- a/services/crud_helper.py +++ b/services/crud_helper.py @@ -126,9 +126,7 @@ def model_patcher( resource_type = _resource_type_for_item(model, item) if resource_type: label = _resource_label(item) - field_changes = _compute_field_changes( - before, item, updates.keys() - ) + field_changes = _compute_field_changes(before, item, updates.keys()) summary = f"Updated {resource_type} {label}" notify_edit_event( user, @@ -197,9 +195,7 @@ def _resource_label(item: Any) -> str: def _snapshot_field_values(item: Any, keys: Any) -> dict[str, Any]: - return { - key: _serialize_field_value(getattr(item, key, None)) for key in keys - } + return {key: _serialize_field_value(getattr(item, key, None)) for key in keys} def _compute_field_changes( diff --git a/services/edit_notification_helper.py b/services/edit_notification_helper.py index 3826cd1c1..13052972b 100644 --- a/services/edit_notification_helper.py +++ b/services/edit_notification_helper.py @@ -134,9 +134,7 @@ def build_slack_payload( heading_action = ACTION_HEADINGS.get(event.action, event.action) header = f"[{env_label}] {heading_action} — {event.resource_label}" - actor_name = ( - user.get("name") or user.get("preferred_username") or "Unknown" - ) + actor_name = user.get("name") or user.get("preferred_username") or "Unknown" actor_email = user.get("email") who = actor_name if not actor_email else f"{actor_name} ({actor_email})" when = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") From e0a2491d86e144a5e783f3993587854ff5048143 Mon Sep 17 00:00:00 2001 From: Peter Rowland Date: Thu, 18 Jun 2026 10:43:13 -0700 Subject: [PATCH 082/160] feat: add ogc_temp_depth_measurements OGC collection Translates the legacy MSSQL TempDepth2_SortedWellName query to a PostgreSQL view returning one row per individual temperature-depth reading. Includes both NAD27 and NAD83 coordinates, elevation datums (GL, unspc, KB), and filters out excluded locations. Exposes 363 858 features via /ogcapi/collections/temp_depth_measurements. Co-Authored-By: Claude Sonnet 4.6 --- ...d2_add_ogc_temp_depth_measurements_view.py | 72 +++++++++++++++++++ core/pygeoapi-config.yml | 23 ++++++ 2 files changed, 95 insertions(+) create mode 100644 alembic/versions/e7f8a9b0c1d2_add_ogc_temp_depth_measurements_view.py diff --git a/alembic/versions/e7f8a9b0c1d2_add_ogc_temp_depth_measurements_view.py b/alembic/versions/e7f8a9b0c1d2_add_ogc_temp_depth_measurements_view.py new file mode 100644 index 000000000..74834da16 --- /dev/null +++ b/alembic/versions/e7f8a9b0c1d2_add_ogc_temp_depth_measurements_view.py @@ -0,0 +1,72 @@ +"""add ogc_temp_depth_measurements view + +Revision ID: e7f8a9b0c1d2 +Revises: d6e7f8a9b0c1 +Create Date: 2026-06-18 + +Individual temperature-depth readings with well header, location, and +elevation data. Translated from the legacy MSSQL TempDepth2_SortedWellName +query against NM_Aquifer. One row per reading; locations with Exclude=1 +are filtered out. +""" + +from alembic import op +from sqlalchemy import text + +revision = "e7f8a9b0c1d2" +down_revision = "d6e7f8a9b0c1" +branch_labels = None +depends_on = None + +_VIEW = "ogc_temp_depth_measurements" + + +def upgrade() -> None: + op.execute(text(f'DROP VIEW IF EXISTS "{_VIEW}"')) + op.execute( + text( + f""" + CREATE VIEW "{_VIEW}" AS + SELECT + td."OBJECTID" AS id, + hdr."CurWellNam" AS well_name, + hdr."CurWellNum" AS well_num, + hdr."API" AS api, + r."SourceID" AS source_id, + s."SampleFm" AS sample_fm, + loc."County" AS county, + loc."State" AS state, + loc."Lat_dd27" AS lat_dd27, + loc."Long_dd27" AS long_dd27, + loc."Lat_dd83" AS lat_dd83, + loc."Long_dd83" AS long_dd83, + loc."LocAccVal" AS loc_acc_val, + s."EnteredBy" AS entered_by, + s."EntryDate" AS entry_date, + td."Depth" AS depth, + s."SmpDpUnt" AS depth_unit, + td."Temp" AS temp, + td."TempUnit" AS temp_unit, + z."Elev_GL" AS elev_gl, + z."Elev_unspc" AS elev_unspc, + z."Elev_KB" AS elev_kb, + s."SampleDate" AS sample_date, + 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 "NMW_WellZDatum" AS z ON z."RecrdsetID" = r."RecrdSetID" + JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + JOIN "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + WHERE loc."Exclude" = 0 + AND loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL + """ + ) + ) + + +def downgrade() -> None: + op.execute(text(f'DROP VIEW IF EXISTS "{_VIEW}"')) diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index 66fcb7d83..cceff876f 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -357,3 +357,26 @@ resources: id_field: id table: ogc_bht_measurements geom_field: geom + + 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] + 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_temp_depth_measurements + geom_field: geom From 7435e6fb83e6dbb8baebf2abd0f7a63eff5db85a Mon Sep 17 00:00:00 2001 From: Peter Rowland Date: Thu, 18 Jun 2026 11:07:02 -0700 Subject: [PATCH 083/160] feat: add NMW_Sources mirror table and transfer spec Adds a 1:1 staging mirror of the NM_Wells tbl_sources publication registry. Wires it into the nmw_mirror_transfer MirrorSpec list so it loads with the rest of the NMW tables. Data loads once tbl_sources.csv is exported from the legacy SQL Server and placed in transfers/data/nma_csv_cache/. Co-Authored-By: Claude Sonnet 4.6 --- ...a9b0c1d2e3_add_nmw_sources_mirror_table.py | 45 +++++++++++++++++++ db/nmw_legacy.py | 30 ++++++++++++- transfers/nmw_mirror_transfer.py | 3 ++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/f8a9b0c1d2e3_add_nmw_sources_mirror_table.py diff --git a/alembic/versions/f8a9b0c1d2e3_add_nmw_sources_mirror_table.py b/alembic/versions/f8a9b0c1d2e3_add_nmw_sources_mirror_table.py new file mode 100644 index 000000000..1a8c8d5f6 --- /dev/null +++ b/alembic/versions/f8a9b0c1d2e3_add_nmw_sources_mirror_table.py @@ -0,0 +1,45 @@ +"""add NMW_Sources mirror table + +Revision ID: f8a9b0c1d2e3 +Revises: e7f8a9b0c1d2 +Create Date: 2026-06-18 + +1:1 mirror of the NM_Wells tbl_sources publication/data-source registry. +Keyed by the free-text SourceID string that appears in NMW_WellRecords.SourceID. +Needed to join publication attribution (FirstAuth, PubYear, Title, etc.) +into the ogc_heat_flow view. +""" + +from alembic import op +import sqlalchemy as sa + +revision = "f8a9b0c1d2e3" +down_revision = "e7f8a9b0c1d2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "NMW_Sources", + sa.Column("OBJECTID", sa.Integer(), nullable=False), + sa.Column("SourceID", sa.String(), nullable=True), + sa.Column("FirstAuth", sa.String(), nullable=True), + sa.Column("PubYear", sa.String(), nullable=True), + sa.Column("Title", sa.String(), nullable=True), + sa.Column("Journal", sa.String(), nullable=True), + sa.Column("Volume", sa.String(), nullable=True), + sa.Column("PageNo", sa.String(), nullable=True), + sa.Column("ReportNo", sa.String(), nullable=True), + sa.Column("Publisher", sa.String(), nullable=True), + sa.Column("City", sa.String(), nullable=True), + sa.Column("URL", sa.String(), nullable=True), + sa.Column("Comments", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("OBJECTID"), + ) + op.create_index("ix_NMW_Sources_SourceID", "NMW_Sources", ["SourceID"]) + + +def downgrade() -> None: + op.drop_index("ix_NMW_Sources_SourceID", table_name="NMW_Sources") + op.drop_table("NMW_Sources") diff --git a/db/nmw_legacy.py b/db/nmw_legacy.py index 4c53c32c9..c20d0cb7d 100644 --- a/db/nmw_legacy.py +++ b/db/nmw_legacy.py @@ -723,10 +723,38 @@ class NMW_WsDstPressure(Base): global_id = mapped_column("GlobalID", UUID(as_uuid=True)) # Drop +# ============================================================================= +# PUBLICATIONS +# ============================================================================= + + +class NMW_Sources(Base): + """1:1 mirror of NM_Wells ``tbl_sources`` (publication / data source registry). + + Transform target: ``publication``. Each row is a citable source keyed by + the free-text ``SourceID`` string that appears in ``NMW_WellRecords.SourceID``. + """ + + __tablename__ = "NMW_Sources" + + object_id = mapped_column("OBJECTID", Integer, primary_key=True) # identity PK + source_id = mapped_column("SourceID", String, index=True) # join key (text FK) + first_auth = mapped_column("FirstAuth", String) + pub_year = mapped_column("PubYear", String) + title = mapped_column("Title", String) + journal = mapped_column("Journal", String) + volume = mapped_column("Volume", String) + page_no = mapped_column("PageNo", String) + report_no = mapped_column("ReportNo", String) + publisher = mapped_column("Publisher", String) + city = mapped_column("City", String) + url = mapped_column("URL", String) + comments = mapped_column("Comments", String) + + # ============================================================================= # TODO(remaining "Migrate First" tables, no DDL/mapping yet) # ----------------------------------------------------------------------------- -# Publications: tbl_sources # Subsurface Library: dst_scan, log_scanned, Well_Header, well_operators # See docs/nm_wells-migration.md for the full inventory + recommendations. # ============================================================================= diff --git a/transfers/nmw_mirror_transfer.py b/transfers/nmw_mirror_transfer.py index 18f930e13..89b9ee1ea 100644 --- a/transfers/nmw_mirror_transfer.py +++ b/transfers/nmw_mirror_transfer.py @@ -62,6 +62,7 @@ NMW_GtHeatFlow, NMW_GtSumHeatFlow, NMW_GtTempDepths, + NMW_Sources, NMW_WellHeaders, NMW_WellLocations, NMW_WellRecords, @@ -111,6 +112,8 @@ class MirrorSpec: MirrorSpec(NMW_WellRecords, "tbl_well_records"), MirrorSpec(NMW_WellZDatum, "tbl_well_z_datum"), MirrorSpec(NMW_WellSamples, "tbl_well_samples"), + # Publications + MirrorSpec(NMW_Sources, "tbl_sources"), # Geothermal MirrorSpec(NMW_GtBhtHeaders, "tbl_gt_bht_headers"), MirrorSpec(NMW_GtBhtData, "tbl_gt_bht_data"), From 88bd152ed3bb4b1dd5b8d705b91193b42b51f24b Mon Sep 17 00:00:00 2001 From: Peter Rowland Date: Thu, 18 Jun 2026 11:18:08 -0700 Subject: [PATCH 084/160] feat: add ogc_heat_flow OGC collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translates the legacy MSSQL HeatFlow query to a PostgreSQL view. Includes CASE WHEN unit conversions (ft->m, TCU->SI, HFU->mW/m²), publication attribution from NMW_Sources, and a LEFT JOIN on Well_Z_Datum for elevation. County WHERE filter removed in favour of API-level filtering. Exposes 1 522 features via /ogcapi/collections/heat_flow. Co-Authored-By: Claude Sonnet 4.6 --- .../a9b0c1d2e3f4_add_ogc_heat_flow_view.py | 109 ++++++++++++++++++ core/pygeoapi-config.yml | 23 ++++ 2 files changed, 132 insertions(+) create mode 100644 alembic/versions/a9b0c1d2e3f4_add_ogc_heat_flow_view.py diff --git a/alembic/versions/a9b0c1d2e3f4_add_ogc_heat_flow_view.py b/alembic/versions/a9b0c1d2e3f4_add_ogc_heat_flow_view.py new file mode 100644 index 000000000..b011a7928 --- /dev/null +++ b/alembic/versions/a9b0c1d2e3f4_add_ogc_heat_flow_view.py @@ -0,0 +1,109 @@ +"""add ogc_heat_flow view + +Revision ID: a9b0c1d2e3f4 +Revises: f8a9b0c1d2e3 +Create Date: 2026-06-18 + +Summary heat-flow records with well header, location, elevation, and +publication attribution. Translated from the legacy MSSQL HeatFlow query +against NM_Aquifer. IIf() unit-conversion expressions translated to +CASE WHEN. County WHERE filter removed — filter via API instead. +One row per GT_SumHeatFlow record. +""" + +from alembic import op +from sqlalchemy import text + +revision = "a9b0c1d2e3f4" +down_revision = "f8a9b0c1d2e3" +branch_labels = None +depends_on = None + +_VIEW = "ogc_heat_flow" + + +def upgrade() -> None: + op.execute(text(f'DROP VIEW IF EXISTS "{_VIEW}"')) + op.execute( + text( + f""" + CREATE VIEW "{_VIEW}" AS + SELECT + shf."OBJECTID" AS id, + hdr."CurWellNam" AS well_name, + hdr."CurWellNum" AS well_num, + hdr."API" AS api, + loc."County" AS county, + loc."State" AS state, + loc."Lat_dd27" AS lat_dd27, + loc."Long_dd27" AS long_dd27, + loc."Lat_dd83" AS lat_dd83, + loc."Long_dd83" AS long_dd83, + r."SourceID" AS source_id, + z."Elev_GL" AS elev_gl, + z."Elev_KB" AS elev_kb, + z."Elev_unspc" AS elev_unspc, + CASE WHEN z."DepthUnits" = 'ft' + THEN 0.3048 * z."Elev_unspc" + ELSE z."Elev_unspc" + END AS elevation_m, + z."DepthUnits" AS depth_units, + hdr."TotalDepth" AS total_depth, + CASE WHEN z."DepthUnits" = 'ft' + THEN 0.3048 * hdr."TotalDepth" + ELSE hdr."TotalDepth" + END AS total_depth_m, + shf."FromDepth" AS from_depth, + shf."ToDepth" AS to_depth, + shf."ThermlCond" AS therml_cond, + shf."TCondRange" AS tcond_range, + shf."TCondError" AS tcond_error, + shf."TCondUnit" AS tcond_unit, + CASE WHEN shf."TCondUnit" = 'TCU' + THEN 0.4184 * shf."ThermlCond" + ELSE shf."ThermlCond" + END AS tc_si, + shf."SampleType" AS sample_type, + shf."NumSamples" AS num_samples, + shf."ThermlGrad" AS therml_grad, + shf."TGradRange" AS tgrad_range, + shf."TGError" AS tg_error, + shf."GradUnit" AS grad_unit, + shf."HeatFlow" AS heat_flow, + shf."HtFlowUnit" AS ht_flow_unit, + CASE WHEN shf."HtFlowUnit" = 'HFU' + THEN 41.84 * shf."HeatFlow" + ELSE shf."HeatFlow" + END AS heat_flow_si, + shf."Quality" AS quality, + src."FirstAuth" AS first_auth, + src."PubYear" AS pub_year, + src."Title" AS title, + src."Journal" AS journal, + src."Volume" AS volume, + src."PageNo" AS page_no, + shf."HtFlowEst" AS ht_flow_est, + r."EntryDate" AS entry_date, + CASE WHEN shf."HtFlowUnit" = 'HFU' + THEN 41.84 * shf."HtFlowEst" + ELSE shf."HtFlowEst" + END AS ht_flow_est_si, + ST_SetSRID( + ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326 + ) AS geom + FROM "NMW_GtSumHeatFlow" AS shf + JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = shf."RecrdSetID" + JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + JOIN "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + LEFT JOIN "NMW_WellZDatum" AS z ON z."RecrdsetID" = r."RecrdSetID" + JOIN "NMW_Sources" AS src ON src."SourceID" = r."SourceID" + WHERE loc."Exclude" = 0 + AND loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL + """ + ) + ) + + +def downgrade() -> None: + op.execute(text(f'DROP VIEW IF EXISTS "{_VIEW}"')) diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index cceff876f..1723aaff4 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -380,3 +380,26 @@ resources: id_field: id table: ogc_temp_depth_measurements geom_field: geom + + 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] + 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_heat_flow + geom_field: geom From 87c4c7f7e36b61cb0180ec3bf387e7b2a8908f5d Mon Sep 17 00:00:00 2001 From: Peter Rowland Date: Thu, 18 Jun 2026 11:23:13 -0700 Subject: [PATCH 085/160] feat: add ogc_dst OGC collection Translates the legacy MSSQL DST query (which never executed in Access due to a broken DST_flwHstryConcat saved query). Replaces the broken cross-join with a string_agg() CTE over NMW_WsDstFlowHistory, concatenating operation descriptions per interval. GROUP BY with no aggregates translated to SELECT DISTINCT. Exposes 1 798 features via /ogcapi/collections/dst. Co-Authored-By: Claude Sonnet 4.6 --- .../versions/b0c1d2e3f4a5_add_ogc_dst_view.py | 102 ++++++++++++++++++ core/pygeoapi-config.yml | 23 ++++ 2 files changed, 125 insertions(+) create mode 100644 alembic/versions/b0c1d2e3f4a5_add_ogc_dst_view.py diff --git a/alembic/versions/b0c1d2e3f4a5_add_ogc_dst_view.py b/alembic/versions/b0c1d2e3f4a5_add_ogc_dst_view.py new file mode 100644 index 000000000..376f7c106 --- /dev/null +++ b/alembic/versions/b0c1d2e3f4a5_add_ogc_dst_view.py @@ -0,0 +1,102 @@ +"""add ogc_dst view + +Revision ID: b0c1d2e3f4a5 +Revises: a9b0c1d2e3f4 +Create Date: 2026-06-18 + +Drill Stem Test records with well header, location, interval, and pressure +data. Translated from the legacy MSSQL DST query against NM_Aquifer. + +The original Access query referenced DST_flwHstryConcat, a broken saved +query that never executed. We replace it with a string_agg() CTE over +NMW_WsDstFlowHistory that concatenates operation descriptions per interval. + +The original GROUP BY with no aggregate functions is equivalent to +SELECT DISTINCT, implemented that way here. +""" + +from alembic import op +from sqlalchemy import text + +revision = "b0c1d2e3f4a5" +down_revision = "a9b0c1d2e3f4" +branch_labels = None +depends_on = None + +_VIEW = "ogc_dst" + + +def upgrade() -> None: + op.execute(text(f'DROP VIEW IF EXISTS "{_VIEW}"')) + op.execute( + text( + f""" + CREATE VIEW "{_VIEW}" AS + WITH flow_history AS ( + SELECT + "DSTInterval", + string_agg("Operation", '; ' ORDER BY "OBJECTID") AS flow_history + FROM "NMW_WsDstFlowHistory" + GROUP BY "DSTInterval" + ) + SELECT DISTINCT + i."OBJECTID" AS id, + hdr."CurWellNam" AS well_name, + hdr."CurWellNum" AS well_num, + hdr."API" AS api, + i."DSTName" AS dst_name, + dh."DSTOprator" AS dst_operator, + i."DSTNumber" AS dst_number, + i."DSTDate" AS dst_date, + loc."County" AS county, + loc."State" AS state, + loc."Lat_dd83" AS lat_dd83, + loc."Long_dd83" AS long_dd83, + s."From_Depth" AS from_depth, + s."To_Depth" AS to_depth, + i."TargetFm" AS target_fm, + i."PackrFrom" AS packer_from, + i."PackerTo" AS packer_to, + i."SrfChokeSz" AS srf_choke_sz, + i."BotChokeSz" AS bot_choke_sz, + s."SmpDpUnt" AS depth_unit, + z."Elev_GL" AS elev_gl, + z."Elev_unspc" AS elev_unspc, + p."PrsGageDpt" AS prs_gage_dpt, + i."PipeDia" AS pipe_dia, + i."PipeLength" AS pipe_length, + fh.flow_history AS flow_history, + p."PrsInShtIn" AS init_flow, + p."FlwPrsInMin" AS flw_prs_in_min, + p."PrsFnShtIn" AS fin_flow, + p."FlwPrsFinMin" AS flw_prs_fin_min, + p."PrsInitClsdIn" AS prs_init_clsd_in, + p."InShtInMin" AS in_sht_in_min, + p."EquilPress" AS fin_shut_in, + p."FnShtInMin" AS fn_sht_in_min, + p."HydrostPrsIn" AS hydrost_prs_in, + p."HydStPrsFl" AS hyd_st_prs_fl, + dh."PressUnits" AS press_units, + p."BlankedOff" AS blanked_off, + p."FmTemp" AS fm_temp, + ST_SetSRID( + ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326 + ) AS geom + FROM "NMW_WsDstIntervals" AS i + JOIN "NMW_WsDstHeaders" AS dh ON dh."DSTGUID" = i."DSTGUID" + JOIN "NMW_WellSamples" AS s ON s."SamplSetID" = dh."SamplSetID" + JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = s."RecrdsetID" + JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + LEFT JOIN "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + LEFT JOIN "NMW_WellZDatum" AS z ON z."RecrdsetID" = r."RecrdSetID" + LEFT JOIN "NMW_WsDstPressure" AS p ON p."DSTInterval" = i."DSTInterval" + LEFT JOIN flow_history AS fh ON fh."DSTInterval" = i."DSTInterval" + WHERE loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL + """ + ) + ) + + +def downgrade() -> None: + op.execute(text(f'DROP VIEW IF EXISTS "{_VIEW}"')) diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index 1723aaff4..45f3bac17 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -403,3 +403,26 @@ resources: id_field: id table: ogc_heat_flow geom_field: geom + + 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] + 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_dst + geom_field: geom From ebc9c503e5a846300106f3a55bae9115f1432c14 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 19:38:50 -0400 Subject: [PATCH 086/160] fix(group): pass user into remove_thing_from_group for audit logging Mirror the POST association route by forwarding the authenticated user into the helper and stamping updated_by before delete via audit_update. --- api/group.py | 2 +- services/audit_helper.py | 6 ++++++ services/group_helper.py | 4 +++- tests/test_group.py | 42 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/api/group.py b/api/group.py index 963c95ba8..d095dfd9c 100644 --- a/api/group.py +++ b/api/group.py @@ -124,7 +124,7 @@ def remove_thing_from_group_route( Remove the association between a thing and a group. Returns 404 if the association does not exist. """ - remove_thing_from_group(session, group_id, thing_id) + remove_thing_from_group(session, group_id, thing_id, user) @router.delete( diff --git a/services/audit_helper.py b/services/audit_helper.py index 425e8ca8e..7efa1e7bc 100644 --- a/services/audit_helper.py +++ b/services/audit_helper.py @@ -25,4 +25,10 @@ def audit_add(user: dict, obj: DeclarativeBase) -> None: obj.created_by_name = user["name"] +def audit_update(user: dict, obj: DeclarativeBase) -> None: + if user and isinstance(user, dict): + obj.updated_by_id = user["sub"] + obj.updated_by_name = user["name"] + + # ============= EOF ============================================= diff --git a/services/group_helper.py b/services/group_helper.py index 9d73333e7..58de1dcec 100644 --- a/services/group_helper.py +++ b/services/group_helper.py @@ -24,7 +24,7 @@ from db.group import Group, GroupThingAssociation from db.thing import Thing from schemas.group import GroupResponse -from services.audit_helper import audit_add +from services.audit_helper import audit_add, audit_update from services.query_helper import order_sort_filter @@ -92,6 +92,7 @@ def remove_thing_from_group( session: Session, group_id: int, thing_id: int, + user: dict, ) -> None: assoc = session.execute( select(GroupThingAssociation).where( @@ -109,6 +110,7 @@ def remove_thing_from_group( ), ) + audit_update(user, assoc) session.delete(assoc) session.commit() diff --git a/tests/test_group.py b/tests/test_group.py index de4c6672a..8ab05879f 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -230,3 +230,45 @@ def test_delete_group_404_not_found(second_group): assert response.status_code == 404 data = response.json() assert data["detail"] == f"Group with ID {bad_id} not found." + + +# GROUP-THING association tests ================================================ + + +def test_add_thing_to_group_route(spring_thing): + payload = { + "release_status": "private", + "name": "Association Test Group", + "description": "Temporary group for association test.", + } + create_response = client.post("/group", json=payload) + assert create_response.status_code == 201 + group_id = create_response.json()["id"] + + response = client.post(f"/group/{group_id}/things/{spring_thing.id}") + assert response.status_code == 201 + data = response.json() + assert data["group_id"] == group_id + assert data["thing_id"] == spring_thing.id + assert data["created_by_id"] == "1234567890" + assert data["created_by_name"] == "foobar" + + cleanup_post_test(GroupThingAssociation, data["id"]) + cleanup_post_test(Group, group_id) + + +def test_add_thing_to_group_route_409_duplicate(group, water_well_thing): + response = client.post(f"/group/{group.id}/things/{water_well_thing.id}") + assert response.status_code == 409 + + +def test_remove_thing_from_group_route(group, water_well_thing): + response = client.delete(f"/group/{group.id}/things/{water_well_thing.id}") + assert response.status_code == 204 + + # restore association for other tests using this fixture + with session_ctx() as session: + session.add( + GroupThingAssociation(group_id=group.id, thing_id=water_well_thing.id) + ) + session.commit() From ea695a0e91cd2b7f9ad87a57dc8f32989f82f4c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:06:37 +0000 Subject: [PATCH 087/160] build(deps): bump actions/checkout from 6.0.3 to 7.0.0 Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.3 to 7.0.0. - [Release notes](https://github.com/actions/checkout/releases) - [Commits](https://github.com/actions/checkout/compare/v6.0.3...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.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/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/tests.yml | 4 ++-- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 1ade7f251..2f89abefd 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@v6.0.3 + uses: actions/checkout@v7.0.0 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 e55c6f2a4..94012dbdb 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@v6.0.3 + uses: actions/checkout@v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index 64e15443e..be7fffb9a 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@v6.0.3 + uses: actions/checkout@v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/format_code.yml b/.github/workflows/format_code.yml index 18acaa9d1..209cec749 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@v6.0.3 + uses: actions/checkout@v7.0.0 - name: Set up Python environment - 3.12 uses: actions/setup-python@v6.2.0 with: @@ -34,7 +34,7 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@v7.0.0 with: ref: ${{ github.head_ref }} - uses: psf/black@stable diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml index 024e07c05..3181a808a 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@v6.0.3 + - uses: actions/checkout@v7.0.0 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@v6.0.3 + - uses: actions/checkout@v7.0.0 with: fetch-depth: 0 ref: ${{ inputs.source_branch }} diff --git a/.github/workflows/hotfix-start.yml b/.github/workflows/hotfix-start.yml index 7bb5ddfcf..4489ce61c 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@v6.0.3 + - uses: actions/checkout@v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/jira_codex_pr.yml b/.github/workflows/jira_codex_pr.yml index 646035232..e94a51ba8 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 with: fetch-depth: 0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c9096369c..55ddff2ef 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@v6.0.3 + uses: actions/checkout@v7.0.0 - name: Wait for database readiness run: | @@ -141,7 +141,7 @@ jobs: steps: - name: Check out source repository - uses: actions/checkout@v6.0.3 + uses: actions/checkout@v7.0.0 - name: Wait for database readiness run: | From 9dab991fd5754e963fb1f3788a563fd13246d156 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:23:14 +0000 Subject: [PATCH 088/160] build(deps): bump the uv-non-major group with 26 updates (#732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the uv-non-major group with 26 updates: | Package | From | To | | --- | --- | --- | | [anyio](https://github.com/agronholm/anyio) | `4.13.0` | `4.14.0` | | [fastapi](https://github.com/fastapi/fastapi) | `0.136.3` | `0.138.0` | | [fastapi-pagination](https://github.com/uriyyo/fastapi-pagination) | `0.15.14` | `0.15.15` | | [google-auth](https://github.com/googleapis/google-cloud-python) | `2.53.0` | `2.55.0` | | [google-cloud-storage](https://github.com/googleapis/google-cloud-python) | `3.11.0` | `3.12.0` | | [greenlet](https://github.com/python-greenlet/greenlet) | `3.5.1` | `3.5.2` | | [numpy](https://github.com/numpy/numpy) | `2.4.6` | `2.5.0` | | [phonenumbers](https://github.com/daviddrysdale/python-phonenumbers) | `9.0.32` | `9.0.33` | | [python-multipart](https://github.com/Kludex/python-multipart) | `0.0.31` | `0.0.32` | | [scramp](https://github.com/tlocke/scramp) | `1.4.8` | `1.4.9` | | [sentry-sdk[fastapi]](https://github.com/getsentry/sentry-python) | `2.62.0` | `2.63.0` | | [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) | `2.0.50` | `2.0.51` | | [pytest](https://github.com/pytest-dev/pytest) | `9.0.3` | `9.1.1` | | [babel](https://github.com/python-babel/babel) | `2.17.0` | `2.18.0` | | [dateparser](https://github.com/scrapinghub/dateparser) | `1.3.0` | `1.4.1` | | [filelock](https://github.com/tox-dev/py-filelock) | `3.18.0` | `3.29.4` | | [markdown-it-py](https://github.com/executablebooks/markdown-it-py) | `4.0.0` | `4.2.0` | | [opentelemetry-api](https://github.com/open-telemetry/opentelemetry-python) | `1.39.1` | `1.42.1` | | [opentelemetry-sdk](https://github.com/open-telemetry/opentelemetry-python) | `1.39.1` | `1.42.1` | | [opentelemetry-semantic-conventions](https://github.com/open-telemetry/opentelemetry-python) | `0.60b1` | `0.63b1` | | [pygeofilter](https://github.com/geopython/pygeofilter) | `0.3.3` | `0.4.0` | | [pyyaml](https://github.com/yaml/pyyaml) | `6.0.2` | `6.0.3` | | [regex](https://github.com/mrabarnett/mrab-regex) | `2026.2.19` | `2026.5.9` | | [sentry-sdk](https://github.com/getsentry/sentry-python) | `2.62.0` | `2.63.0` | | [tzlocal](https://github.com/regebro/tzlocal) | `5.3.1` | `5.4.3` | | [werkzeug](https://github.com/pallets/werkzeug) | `3.1.6` | `3.1.8` | Updates `anyio` from 4.13.0 to 4.14.0
Release notes

Sourced from anyio's releases.

4.14.0

  • Added support for Python 3.15

  • Added an asynchronous implementation of the itertools module (#998; PR by @​11kkw)

  • Added the local_port parameter to connect_tcp() to allow binding to a specific local port before connecting (#1067; PR by @​nullwiz)

  • Added support for custom capacity limiters in async path and file I/O functions and classes

  • Added the create_task() task group method for easier asyncio migration (returns a TaskHandle) (#1098)

  • Changed TaskGroup.start_soon() to return a TaskHandle

  • Added an option for TaskGroup.start() to return a TaskHandle (which then contains the start value in the start_value property)

  • Added the cancel() convenience method to TaskGroup as a shortcut for cancelling the task group's cancel scope

  • Improved the error message when a known backend is not installed to suggest the install command (#1115; PR by @​EmmanuelNiyonshuti)

  • Improved anyio.Path to preserve subclass types by returning Self in methods that return path objects (#1130; PR by @​EmmanuelNiyonshuti)

  • Changed the parameter type annotation in anyio.Path.write_bytes() to accept any ReadableBuffer, thus allowing it to accept bytearray and memoryview to match pathlib.Path.write_bytes() (#1135; PR by @​SAY-5)

  • Changed several type annotations to only accept callables returning coroutine-like objects instead of arbitrary awaitables:

    • TaskGroup.start_soon()
    • TaskGroup.start()
    • anyio.from_thread.run()

    This reverts an earlier change from v3.7.0 which was made in error. (#1153)

  • Changed anyio.run to support callables returning arbitrary awaitables at runtime on all backends. Previously, this only worked on asyncio (#1171; PR by @​gschaffner)

  • Changed several classes (and their subclasses) to have __slots__ (with __weakref__):

    • anyio.CancelScope
    • anyio.CapacityLimiter
    • anyio.Condition
    • anyio.Event
    • anyio.Lock
    • anyio.ResourceGuard
    • anyio.Semaphore
  • Fixed cancellation exception escaping a cancel scope when triggered via check_cancelled() in a worker thread (#1113)

  • Fixed TaskGroup raising AttributeError instead of a clear error when entered more than once (#1109; PR by @​bahtya)

  • Fixed lost type information when passing arguments to lru_cache (#1104; PR by @​Graeme22)

  • Fixed test resumption after KeyboardInterrupt in async generator fixtures on the asyncio backend (#1060; PR by @​EmmanuelNiyonshuti)

... (truncated)

Commits
  • ffe9133 Bumped up the version
  • f8b9f01 Fixed asyncio lock waiter deadlocks after cancellation (#1145)
  • d517ee1 [pre-commit.ci] pre-commit autoupdate (#1176)
  • 550b68e Make anyio.run support Awaitable at runtime on all backends (#1171)
  • 29a5e04 Fixed FastAPI test run
  • 4d752ac Updated downstream test setups for FastAPI and Anthropic MCP
  • ebdc950 Added task handle support to start() and start_soon() (#1153)
  • f32bfb8 Fixed test suite compatibility issues with Pytest 9.1.0
  • 85f7e8e Added __slots__ to several classes
  • b7ea84c [pre-commit.ci] pre-commit autoupdate (#1165)
  • Additional commits viewable in compare view

Updates `fastapi` from 0.136.3 to 0.138.0
Release notes

Sourced from fastapi's releases.

0.138.0

Features

  • ✨ Add support for app.frontend("/", directory="dist") and router.frontend("/", directory="dist"). PR #15800 by @​tiangolo.

Docs

Translations

Internal

0.137.2

Features

  • ✨ Add iter_route_contexts() for advanced use cases that used to use router.routes (e.g. Jupyverse). PR #15785 by @​tiangolo.

Translations

Internal

... (truncated)

Commits

Updates `fastapi-pagination` from 0.15.14 to 0.15.15
Release notes

Sourced from fastapi-pagination's releases.

0.15.15

What's Changed

  • Fix compatibility issue with fastapi 0.137.0 #1930

Full Changelog: https://github.com/uriyyo/fastapi-pagination/compare/0.15.14...0.15.15

Commits
  • 4b0654f Bump to next version [no ci]
  • 3236a44 Fix compatibility issue with fastapi 0.137.0 (#1930)
  • 1f67092 Merge pull request #1921 from uriyyo/dependabot/uv/peewee-4.0.7
  • 86752dd Bump peewee from 4.0.6 to 4.0.7
  • cba49a0 Merge pull request #1920 from uriyyo/dependabot/uv/ruff-0.15.17
  • 5071a70 Bump ruff from 0.15.16 to 0.15.17
  • c4671ad Merge pull request #1919 from uriyyo/dependabot/uv/ty-0.0.48
  • a6534ec Bump ty from 0.0.46 to 0.0.48
  • e21fd47 Merge pull request #1918 from uriyyo/dependabot/uv/faker-40.23.0
  • 8b12f5a Bump faker from 40.22.0 to 40.23.0
  • Additional commits viewable in compare view

Updates `google-auth` from 2.53.0 to 2.55.0
Release notes

Sourced from google-auth's releases.

google-auth: v2.55.0

v2.55.0 (2026-06-15)

Features

Bug Fixes

  • run async background boundary refresh on detached session (#17441) (56cbea85)

google-auth: v2.54.0

v2.54.0 (2026-06-11)

Features

  • implement regional access boundary support for standalone JWT and async service accounts (#17025) (35af6168)

Bug Fixes

  • configure mTLS for impersonated credentials (#17404) (57269d56)

  • fail-fast on missing ECP config file to avoid 30s hang (#17377) (e0961270)

  • Rename the &#39;seed&#39; argument for setting an initial regional access boundary for clarity (#17186) (e5c8cf92)

  • update incorrect urls in setup.py to point at monorepo vs splitrepo (#17237) (eaed04ba)

Commits
  • 08a8f90 chore: librarian release pull request: 20260615T173024Z (#17468)
  • 305f5bd test(auth): assert quota project header injection in google-auth tests (#17448)
  • af19393 feat(auth): make RAB feature production ready (#17390)
  • 00ec9bf chore: release bigframes v2.43.0 (#17460)
  • f4945bd tests: fix compatibility with pytest 9.1.0 (#17465)
  • 56cbea8 fix(rab): run async background boundary refresh on detached session (#17441)
  • b50cf1a chore(google-auth): drop python 3.7 EOL false positives and refactor metrics ...
  • 145034a fix: preserve aliases on cast columns and fix star selection in sqlglot (#173...
  • dd59d36 chore: address pandas 3 failure and remove inherently flaky system test (#17452)
  • 4d3447d chore: skip sqlalchemy-bigquery test to unblock CI (#17288)
  • Additional commits viewable in compare view

Updates `google-cloud-storage` from 3.11.0 to 3.12.0
Release notes

Sourced from google-cloud-storage's releases.

google-cloud-storage: v3.12.0

v3.12.0 (2026-06-11)

Features

  • full object checksum: implement rolling checksum and verification in reads resumption strategy (#17262) (2361ba6e)

  • Enable full object checksum PR 1/3 : parse finalize_time and server crc32c in async object stream (#17261) (72c7a272)

  • full object checksum: integrate full-object checksum in AsyncMultiRangeDownloader (#17263) (b6a85e49)

Changelog

Sourced from google-cloud-storage's changelog.

3.12.0 (2026-03-23)

Features

Commits
  • 6547012 chore: librarian release pull request: 20260611T192009Z (#17432)
  • 2e75c78 feat: update API sources and regenerate (#17431)
  • f59c2b2 fix: bump pyarrow from 15.0.2 to 23.0.1 in /packages/bigframes (#17386)
  • dd823f5 chore(bigtable): add bigtable samples (#17240)
  • 7d230af chore(deps): bump pyspark from 3.5.1 to 3.5.2 in /packages/bigframes (#17400)
  • 57269d5 fix(auth): configure mTLS for impersonated credentials (#17404)
  • 59fe7cf feat: update API sources and regenerate (#17413)
  • ca02afc feat(google/developers/knowledge/v1): add google-developer-knowledge (#17417)
  • 3a90cc8 fix(bigframes): improve error message when unescaped { are found in SQL cel...
  • 384724c feat: support row_range in sample_row_keys method (#17330)
  • Additional commits viewable in compare view

Updates `greenlet` from 3.5.1 to 3.5.2
Changelog

Sourced from greenlet's changelog.

3.5.2 (2026-06-17)

  • The minimum supported version of Python 3.15 is now 3.15b2.
  • Fix some garbage-collection related crashes on free-threaded Python 3.15. Thanks to Kumar Aditya in PR [#511](https://github.com/python-greenlet/greenlet/issues/511) <https://github.com/python-greenlet/greenlet/pull/511>_.
  • Improve garbage collection of greenlets. This mostly applies to Python 3.15. Thanks to Kumar Aditya in PR [#512](https://github.com/python-greenlet/greenlet/issues/512) <https://github.com/python-greenlet/greenlet/pull/512>_.
Commits
  • 0b64e9c Preparing release 3.5.2
  • 3e28d27 Add change note for #512 [skip ci]
  • 6563c5e Merge pull request #512 from kumaraditya303/ft-mem
  • ab6eff6 add ignore for win 3.10
  • 41f5349 revert back to fails_leakcheck_on_py314_or_less
  • b0aac05 set fail-fast=false and if condition correctly
  • 2f87f31 rename to ignores_leakcheck_on_py314_or_less
  • 28bbde3 add comments
  • 35206b8 fix test and restrict tp_is_gc < 3.15
  • abdbab5 fix gil enabled
  • Additional commits viewable in compare view

Updates `numpy` from 2.4.6 to 2.5.0
Release notes

Sourced from numpy's releases.

v2.5.0 (June 21, 2026)

NumPy 2.5.0 Release Notes

Numpy 2.5.0 is a transitional release. It drops support for Python 3.11, marking the end of distutils, and expires a large number of deprecations made in the 2.0.x release. It also improves free threading and brings sorting into compliance with the array-api standard with the addition of descending sorts. There is also a fair amount of preparation for Python 3.15, which will be supported starting with the first rc.

This release supports Python versions 3.12-3.14.

Highlights

  • Distutils has been removed,
  • Many expired deprecations, see below,
  • Many new deprecations, see below,
  • Many static typing improvements.
  • Improved support for free threading,
  • Support for descending sorts,

See New Features below for other additions.

Deprecations

  • numpy.char.chararray is deprecated. Use an ndarray with a string or bytes dtype instead.

    (gh-30605)

  • numpy.take now correctly checks if the result can be cast to the provided out=out under the same-kind rule. A DeprecationWarning is given now when this check fails. Previously, take incorrectly checked if out could be cast to the result (the wrong direction). This deprecation also affects compress and possibly other functions. (Future versions of NumPy may tighten the casting check further.)

    (gh-30615)

  • The numpy.char.[as]array functions are deprecated. Use an numpy.[as]array with a string or bytes dtype instead.

    (gh-30802)

  • Setting the dtype attribute is deprecated because mutating an array is unsafe if an array is shared, especially by multiple threads. As an alternative, you can create a view with a new dtype via array.view(dtype=new_dtype).

    (gh-29244)

... (truncated)

Commits
  • 6910b28 Merge pull request #31706 from charris/prepare-2.5.0-release
  • e0acd2b REL: Prepare for the NumPy 2.5.0 release.
  • 8d928b7 Merge pull request #31704 from charris/backport-31649
  • c2055ba MAINT: update openblas to 0.3.33.112.0 (#31649)
  • ce17c81 Merge pull request #31703 from charris/backport-31609
  • 3de6203 BUG: fix StringDType distinct-allocator bugs and add tests (#31609)
  • c723971 Merge pull request #31700 from charris/backport-31694
  • 64513b2 MAINT: Bump pypa/cibuildwheel from 3.4.1 to 4.1.0
  • 04707f0 Merge pull request #31698 from charris/try-fix-emscripten
  • 5cf0686 MAINT: Try to fix emscripten wheel build.
  • Additional commits viewable in compare view

Updates `phonenumbers` from 9.0.32 to 9.0.33
Commits

Updates `python-multipart` from 0.0.31 to 0.0.32
Release notes

Sourced from python-multipart's releases.

Version 0.0.32

What's Changed

Full Changelog: https://github.com/Kludex/python-multipart/compare/0.0.31...0.0.32

Changelog

Sourced from python-multipart's changelog.

0.0.32 (2026-06-04)

  • Speed up partial-boundary scanning for CR/LF-dense part data #300.
Commits

Updates `scramp` from 1.4.8 to 1.4.9
Commits

Updates `sentry-sdk[fastapi]` from 2.62.0 to 2.63.0
Release notes

Sourced from sentry-sdk[fastapi]'s releases.

2.63.0

Bug Fixes 🐛

Fastapi

Other

Internal Changes 🔧

Changelog

Sourced from sentry-sdk[fastapi]'s changelog.

2.63.0

Bug Fixes 🐛

Fastapi

Other

Internal Changes 🔧

Commits
  • 44b008a update changelog
  • 0b2af51 Update CHANGELOG.md
  • 250caad release: 2.63.0
  • 72a57de fix(flask): Set user data on scope at request start (#6566)
  • 6a4c3a1 fix: Remove 0000 trace_id fallbacks (#6570)
  • 1df9835 feat(falcon): Set name and source on request span when streaming (#6562)
  • 77874bd test(falcon): Support span streaming (#6561)
  • 6bcfb9c fix(fastapi): Prevent double wrapping of sync handlers on FastAPI >= 0.137 (#...
  • 72d972c fix(fastapi): use effective_route_context path for prefixed routers (#6572)
  • cc802f6 feat(chalice): Add span streaming support to Chalice integration (#6503)
  • Additional commits viewable in compare view

Updates `sqlalchemy` from 2.0.50 to 2.0.51
Release notes

Sourced from sqlalchemy's releases.

2.0.51

Released: June 15, 2026

orm

  • [orm] [bug] Fixed issue where _orm.subqueryload() combined with PropComparator.of_type() and PropComparator.and_() would silently drop the additional filter criteria, causing all related objects to be loaded instead of only those matching the filter. The LoaderCriteriaOption was being constructed against the base entity rather than the effective entity indicated by PropComparator.of_type(). Pull request courtesy Arya Rizky.

    References: #13207

  • [orm] [bug] Fixed bug where a failure during tpc_prepare() within _orm.Session.commit() for a two-phase session would raise IllegalStateChangeError instead of the original database exception. The internal _prepare_impl() method's error handler was unable to invoke _orm.SessionTransaction.rollback() due to a state-change guard, preventing proper cleanup and masking the underlying error.

    References: #13356

engine

  • [engine] [bug] Fixed issue where Result.freeze() would lose track of ambiguous column names present in the original CursorResult, causing key-based access on the thawed result to silently return a value instead of raising InvalidRequestError. The SimpleResultMetaData now accepts and propagates ambiguous key information so that frozen, thawed, and pickled results raise consistently for duplicate column names. Pull request courtesy Saurabh Kohli.

    References: #9427

sql

  • [sql] [bug] Fixed issue where _sql.StatementLambdaElement would proxy attribute access through the cached "expected" expression rather than the resolved expression, causing stale closure-bound parameter values to be used when a lambda statement was extended with non-lambda criteria such as an additional .where() clause. Courtesy cjc0013.

    References: #10827

... (truncated)

Commits

Updates `pytest` from 9.0.3 to 9.1.1
Release notes

Sourced from pytest's releases.

9.1.1

pytest 9.1.1 (2026-06-19)

Bug fixes

  • #14220: Fixed a logic bug in pytest.RaisesGroup which would might cause it to display incorrect "It matches FooError() which was paired with BarError" messages.
  • #14591: Fixed a regression in pytest 9.1.0 which caused overriding a parametrized fixture with an indirect @​pytest.mark.parametrize to fail with "duplicate parametrization of '<fixture name>'".
  • #14606: Fixed list-item typing errors from mypy in @pytest.mark.parametrize <pytest.mark.parametrize ref> argvalues parameter.
  • #14608: Fixed a regression in pytest 9.1.0 where conftest.py files located in <invocation dir>/test* were no longer loaded as initial conftests when invoked without arguments. This could cause certain hooks (like pytest_addoption) in these files to not fire.

9.1.0

pytest 9.1.0 (2026-06-13)

Removals and backward incompatible breaking changes

  • #14533: When using --doctest-modules, autouse fixtures with module, package or session scope that are defined inline in Python test modules (not plugins or conftests) will now possibly execute twice.

    If this is undesirable, move the fixture definition to a conftest.py file if possible.

    Technical explanation for those interested: When using --doctest-modules, pytest possibly collects Python modules twice, once as pytest.Module and once as a DoctestModule (depending on the configuration). Due to improvements in pytest's fixture implementation, if e.g. the DoctestModule collects a fixture, it is now visible to it only, and not to the Module. This means that both need to register the fixtures independently.

Deprecations (removal in next major release)

  • #10819: Added a deprecation warning for class-scoped fixtures defined as instance methods (without @classmethod). Such fixtures set attributes on a different instance than the test methods use, leading to unexpected behavior. Use @classmethod decorator instead -- by yastcher.

    See 10819 and 14011.

  • #12882: Calling request.getfixturevalue() <pytest.FixtureRequest.getfixturevalue> during teardown to request a fixture that was not already requested is now deprecated and will become an error in pytest 10.

    See dynamic-fixture-request-during-teardown for details.

  • #13409: Using non-~collections.abc.Collection iterables (such as generators, iterators, or custom iterable objects) for the argvalues parameter in @pytest.mark.parametrize <pytest.mark.parametrize ref> and metafunc.parametrize <pytest.Metafunc.parametrize> is now deprecated.

    These iterables get exhausted after the first iteration, leading to tests getting unexpectedly skipped in cases such as running pytest.main() multiple times, using class-level parametrize decorators, or collecting tests multiple times.

    See parametrize-iterators for details and suggestions.

  • #13946: The private config.inicfg attribute is now deprecated. Use config.getini() <pytest.Config.getini> to access configuration values instead.

    See config-inicfg for more details.

  • #14004: Passing baseid to ~pytest.FixtureDef or nodeid strings to fixture registration APIs is now deprecated. These are internal pytest APIs that are used by some plugins.

... (truncated)

Commits
  • cf470ec Prepare release version 9.1.1
  • e0c8ce6 Merge pull request #14625 from pytest-dev/patchback/backports/9.1.x/a07c31a97...
  • 1b82d16 Merge pull request #14624 from pytest-dev/patchback/backports/9.1.x/b375b79ec...
  • 501c4bc Merge pull request #14596 from bluetech/doc-classmethod
  • b61f588 Merge pull request #14622 from chrisburr/fix-14608-initial-conftest-test-subdir
  • 9a567e0 [automated] Update plugin list (#14617) (#14618)
  • ef8b299 Merge pull request #14620 from pytest-dev/patchback/backports/9.1.x/680f9f3ed...
  • 66abd07 Merge pull request #14220 from bysiber/fix-stale-iexp-raisesgroup
  • 79fbf93 Merge pull request #14612 from pytest-dev/patchback/backports/9.1.x/974ed48b6...
  • 0d312eb Merge pull request #14611 from bluetech/parametrize-argvalues-typing
  • Additional commits viewable in compare view

Updates `babel` from 2.17.0 to 2.18.0
Release notes

Sourced from babel's releases.

v2.18.0

Happy 2026! Like last year's release (ahem...), this one too is being made from FOSDEM 2026, in Brussels, Belgium. 🇧🇪 We'll aspire for a less glacial release cycle for 2.19. 😁

Please see CHANGELOG.rst for the detailed change log.

Full Changelog: https://github.com/python-babel/babel/compare/v2.17.0...v2.18.0

Changelog

Sourced from babel's changelog.

Version 2.18.0

Happy 2026! This release is, coincidentally, also being made from FOSDEM.

We will aspire for a slightly less glacial release cadence in this year; there are interesting features in the pipeline.

Features


* Core: Add `babel.core.get_cldr_version()` by @akx in :gh:`1242`
* Core: Use CLDR 47 by @tomasr8 in :gh:`1210`
* Core: Use canonical IANA zone names in zone_territories by @akx in
:gh:`1220`
* Messages: Improve extract performance via ignoring directories early
during os.walk by @akx in :gh:`968`
* Messages: Merge in per-format keywords and auto_comments by @akx in
:gh:`1243`
* Messages: Update keywords for extraction of dpgettext and dnpgettext
by @mardiros in :gh:`1235`
* Messages: Validate all plurals in Python format checker by @tomasr8 in
:gh:`1188`
* Time: Use standard library `timezone` instead of `FixedOffsetTimezone`
by @akx in :gh:`1203`

Bugfixes

  • Core: Fix formatting for "Empty locale identifier" exception added in #1164 by @​akx in :gh:1184
  • Core: Improve handling of no-inheritance-marker in timezone data by @​akx in :gh:1194
  • Core: Make the number pattern regular expression more efficient by @​akx in :gh:1213
  • Messages: Keep translator comments next to the translation function call by @​akx in :gh:1196
  • Numbers: Fix KeyError that occurred when formatting compact currencies of exactly one thousand in several locales by @​bartbroere in :gh:1246

Other improvements


* Core: Avoid unnecessary uses of `map()` by @akx in :gh:`1180`
* Messages: Have init-catalog create directories too by @akx in
:gh:`1244`
* Messages: Optimizations for read_po by @akx in :gh:`1200`
* Messages: Use pathlib.Path() in catalog frontend; improve test
coverage by @akx in :gh:`1204`

Infrastructure and documentation

.csv`. +- I.db — 18 `NMW_*` mirror tables (`db/nmw_legacy.py`), PK verified vs dump DDL. +- I.migrations — `c0d1e2f3a4b5` (tables+FK), `d1e2f3a4b5c6` (per-well views), `e2f3a4b5c6d7` (measurement views). Chain down_rev: t6u7v8w9x0y1 → c0 → d1 → e2. +- I.ogc — 6 new collections in `core/pygeoapi-config.yml`: geothermal_wells_bht, geothermal_wells_temperature_profile (MATVIEW), bht_measurements, temp_depth_measurements, heat_flow, dst. +- I.views — DB: ogc_geothermal_wells_bht, ogc_geothermal_wells_temperature_profile (MAT), ogc_geothermal_wells_summary_heat_flow, ogc_geothermal_wells_interval_heat_flow, ogc_bht_measurements, ogc_temp_depth_measurements, ogc_heat_flow, ogc_dst. +- I.lexicon — `reference_lexicon_transfer.py` maps 46 `ref_*` → `nmw_ref_*` LexiconCategory + terms. + +## §V — invariants + +- V1. `NMW_*` cols match legacy NM_Wells DDL exactly (name+type+PK). No renames except dropped `SSMA_TimeStamp`. +- V2. Mirror load respects parent→child order in `NMW_MIRROR_SPECS`; child never loads before parent. +- V3. `alembic upgrade head` then `downgrade` cleanly creates+drops all 18 tables + 8 views, no orphans. +- V4. Re-running mirror load is non-destructive at row level (truncate+COPY full reload OR ON CONFLICT skip) — no dup rows, no partial-state corruption. +- V5. After mirror load, `ogc_geothermal_wells_temperature_profile` matview refreshed; stale matview never served. +- V6. Each of 6 OGC collections resolves to existing backing view; pygeoapi config view name == migration view name. +- V7. `core/pygeoapi.py` unchanged from staging (reviewer note: reverted to original). +- V8. `NMW_WellRecords.SourceID` joined as TEXT (free-text citation), not numeric FK. +- V9. P2 lexicon mapping complete: every coded NMW col either in `LEXICON_REF_BY_COLUMN` (28, has ref_*) or `LEXICON_CANDIDATES_NO_REF` (11, needs new enum). +- V10. ORM `NMW_*` models declare index only (`index=True`, no ORM `ForeignKey`); FK enforcement lives in migration `c0d1e2f3a4b5` (`op.create_foreign_key`). Keep both in sync. +- V11. SQL-dump parser unwraps `CAST(expr AS )` for parameterised types too (`Decimal(18,2)`, `nvarchar(10)`); never store the literal `CAST(...)` string in a mirror column. + +## §T — tasks + +id|status|task|cites +T1|x|18 NMW_* mirror tables in db/nmw_legacy.py|V1,I.db +T2|x|migration c0d1e2f3a4b5 tables+FK|V3,I.migrations +T3|x|migration d1e2f3a4b5c6 per-well OGC views|I.views +T4|x|migration e2f3a4b5c6d7 measurement OGC views|I.views +T5|x|nmw_sql_dump.py SSMS dump parser|I.cli +T6|x|nmw_mirror_transfer.py loader (dump+CSV)|V2,V4,I.cli +T7|x|reference_lexicon_transfer.py ref_*→lexicon|I.lexicon +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 +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|- +T16|.|P2 (later): transform NMW_* → Ocotillo model; build new enums for 11 LEXICON_CANDIDATES_NO_REF|C2,V9 +T17|x|landed docs/nm_wells-migration.md (commit ccf566d9; force-add, docs/ gitignored). Referenced 4x: nmw_legacy.py:75,81,759; nmw_mirror_transfer.py:19|- + +## §B — bugs + +id|date|cause|fix +B1|2026-06-23|_CAST_RE in transfers/nmw_sql_dump.py matched AS-type without parens only; parenthesised types (nvarchar(10), Decimal(18,2)) left value as literal "CAST(...)" string|V11; widened regex to allow one paren level diff --git a/tests/test_nmw_mirror.py b/tests/test_nmw_mirror.py new file mode 100644 index 000000000..2756172b5 --- /dev/null +++ b/tests/test_nmw_mirror.py @@ -0,0 +1,243 @@ +# =============================================================================== +# 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. +# =============================================================================== +"""Structural + unit tests for the NM_Wells Phase-1 staging mirror. + +Covers SPEC §V invariants for the mirror schema, migrations and OGC views: + + V1 - all 18 NMW_* mirror tables exist with a primary key + V2 - mirror load order respects parent->child (FK parent precedes child) + V3 - migrations build all 8 OGC views + V5 - the temperature-profile OGC view is MATERIALIZED + V6 - each geothermal pygeoapi collection maps to an existing DB relation + V10 - FK enforcement lives in the migration (DB-level FK constraints exist) + +Full data round-trip against a real SQL dump is out of scope here (SPEC §T.T14); +the dump parser is unit-tested directly instead. +""" + +import os + +import pytest +import yaml +from sqlalchemy import inspect as sa_inspect, text + +from db.engine import engine, session_ctx +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__)) + +# DB relations created by the OGC-view migrations (d1e2f3a4b5c6, e2f3a4b5c6d7). +OGC_VIEWS = [ + "ogc_geothermal_wells_bht", + "ogc_geothermal_wells_temperature_profile", # MATERIALIZED + "ogc_geothermal_wells_summary_heat_flow", + "ogc_geothermal_wells_interval_heat_flow", + "ogc_bht_measurements", + "ogc_temp_depth_measurements", + "ogc_heat_flow", + "ogc_dst", +] +MATERIALIZED_VIEW = "ogc_geothermal_wells_temperature_profile" + +# pygeoapi collections added by this PR and the DB relation each is backed by. +GEOTHERMAL_COLLECTIONS = { + "geothermal_wells_bht": "ogc_geothermal_wells_bht", + "geothermal_wells_temperature_profile": "ogc_geothermal_wells_temperature_profile", + "bht_measurements": "ogc_bht_measurements", + "temp_depth_measurements": "ogc_temp_depth_measurements", + "heat_flow": "ogc_heat_flow", + "dst": "ogc_dst", +} + + +def _mirror_tablenames() -> list[str]: + return [spec.model.__tablename__ for spec in NMW_MIRROR_SPECS] + + +# --------------------------------------------------------------------------- V1 +def test_all_mirror_tables_present_with_pk(): + """All 18 NMW_* mirror tables exist in the schema, each with a PK (V1).""" + names = _mirror_tablenames() + assert len(names) == 18, f"expected 18 mirror specs, got {len(names)}" + + insp = sa_inspect(engine) + existing = set(insp.get_table_names()) + for table in names: + assert table in existing, f"mirror table {table} missing from schema" + pk = insp.get_pk_constraint(table)["constrained_columns"] + assert pk, f"mirror table {table} has no primary key" + + +def test_well_headers_pk_is_well_data_id(): + """Spot-check that original SQL Server column names are preserved (V1).""" + insp = sa_inspect(engine) + pk = insp.get_pk_constraint("NMW_WellHeaders")["constrained_columns"] + assert pk == ["WellDataID"] + + +# ----------------------------------------------------------------------- V2/V10 +def test_mirror_tables_have_fk_constraints(): + """The migration creates DB-level FK constraints (V10) - at least one + child table must carry a foreign key.""" + insp = sa_inspect(engine) + total_fks = sum(len(insp.get_foreign_keys(t)) for t in _mirror_tablenames()) + assert total_fks > 0, "no FK constraints found on NMW_* mirror tables" + + +def test_fk_parent_loads_before_child(): + """Every FK parent table is loaded before its child in NMW_MIRROR_SPECS so + the parent row exists when the child is inserted (V2).""" + order = {name: i for i, name in enumerate(_mirror_tablenames())} + insp = sa_inspect(engine) + checked = 0 + for child in order: + for fk in insp.get_foreign_keys(child): + parent = fk["referred_table"] + if parent not in order or parent == child: + continue # self-ref or FK to a non-mirror table + checked += 1 + assert order[parent] <= order[child], ( + f"{parent} (parent) must load before {child} (child) " + f"in NMW_MIRROR_SPECS" + ) + assert checked > 0, "expected at least one intra-mirror FK to validate" + + +# --------------------------------------------------------------------------- V3 +def test_ogc_views_exist(): + """All 8 OGC views built by the migrations exist as relations (V3).""" + with session_ctx() as session: + rows = session.execute( + text( + "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = 'public' " + "UNION SELECT matviewname FROM pg_matviews WHERE schemaname = 'public'" + ) + ).all() + relations = {r[0] for r in rows} + for view in OGC_VIEWS: + assert view in relations, f"OGC view {view} missing" + + +# --------------------------------------------------------------------------- V5 +def test_temperature_profile_is_materialized(): + """The temperature-profile view is MATERIALIZED so it can be refreshed + after a mirror load (V5).""" + with session_ctx() as session: + names = { + r[0] + for r in session.execute( + text("SELECT matviewname FROM pg_matviews WHERE schemaname = 'public'") + ).all() + } + assert MATERIALIZED_VIEW in names, f"{MATERIALIZED_VIEW} is not a materialized view" + + +# --------------------------------------------------------------------------- V6 +class _Default(dict): + """format_map() helper: unknown placeholders render empty.""" + + def __missing__(self, key): # noqa: D401 + return "" + + +def _load_pygeoapi_config() -> dict: + """pygeoapi-config.yml is a ``{placeholder}`` template (see core/pygeoapi.py + _write_config); substitute dummy values before parsing as YAML.""" + raw = open(os.path.join(ROOT, "core", "pygeoapi-config.yml")).read() + rendered = raw.format_map( + _Default( + server_url="http://test", + postgres_host="h", + postgres_port="5432", + postgres_db="d", + postgres_user="u", + postgres_password_env="p", + thing_collections_block="", + ) + ) + return yaml.safe_load(rendered) + + +def test_geothermal_collections_back_existing_relations(): + """Each new geothermal pygeoapi collection points at a DB relation that + actually exists (V6).""" + cfg = _load_pygeoapi_config() + resources = cfg["resources"] + + with session_ctx() as session: + rows = session.execute( + text( + "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = 'public' " + "UNION SELECT matviewname FROM pg_matviews WHERE schemaname = 'public'" + ) + ).all() + relations = {r[0] for r in rows} + + for coll, expected_table in GEOTHERMAL_COLLECTIONS.items(): + assert coll in resources, f"collection {coll} missing from pygeoapi config" + tables = { + p.get("table") + for p in resources[coll].get("providers", []) + if p.get("table") + } + assert ( + expected_table in tables + ), f"collection {coll} should be backed by {expected_table}, got {tables}" + assert ( + expected_table in relations + ), f"backing relation {expected_table} for {coll} does not exist in DB" + + +# ----------------------------------------------------------------- dump parser +@pytest.mark.parametrize( + "raw,expected", + [ + ("NULL", None), + ("null", None), + ("123", 123), + ("-5", -5), + ("-1.5", -1.5), + ("'abc'", "abc"), + ("N'abc'", "abc"), + ("'O''Brien'", "O'Brien"), # doubled '' unescaped + ("CAST(42 AS int)", 42), + ("CAST(N'x' AS nvarchar(10))", "x"), + ("0xDEADBEEF", None), # binary / rowversion not mirrored + ], +) +def test_parse_value_coercion(raw, expected): + assert _parse_value(raw) == expected + + +def test_iter_table_rows_parses_inserts(tmp_path): + """iter_table_rows decodes column/value pairs from SSMS INSERT statements.""" + dump = tmp_path / "dump.sql" + dump.write_text( + "INSERT [dbo].[tbl_demo] ([OBJECTID], [Name], [Note]) " + "VALUES (1, N'alpha', NULL), (2, 'beta', CAST(N'c' AS nvarchar(1)));\n", + encoding="utf-8", + ) + rows = list(iter_table_rows(str(dump), "tbl_demo")) + assert rows == [ + {"OBJECTID": 1, "Name": "alpha", "Note": None}, + {"OBJECTID": 2, "Name": "beta", "Note": "c"}, + ] + + +# ============= EOF ============================================= diff --git a/transfers/nmw_sql_dump.py b/transfers/nmw_sql_dump.py index 29f72e735..f7010b849 100644 --- a/transfers/nmw_sql_dump.py +++ b/transfers/nmw_sql_dump.py @@ -122,7 +122,9 @@ def _iter_value_groups(s: str) -> Iterator[str]: i += 1 -_CAST_RE = re.compile(r"(?is)^CAST\s*\((.*)\s+AS\s+[^)]+\)$") +# The AS-target type may itself be parameterised, e.g. CAST(1.50 AS Decimal(18, 2)) +# or CAST(N'x' AS nvarchar(10)); allow one level of parens in the type name. +_CAST_RE = re.compile(r"(?is)^CAST\s*\((.*)\s+AS\s+[^()]+(?:\([^)]*\))?\s*\)$") def _parse_value(tok: str): From 9fba496cef143fa2f9b129a73ef218629cab997b Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 23 Jun 2026 19:40:15 -0600 Subject: [PATCH 098/160] fix(alembic): merge nmw mirror chain into staging head Merging staging left two alembic heads: e2f3a4b5c6d7 (NMW mirror + OGC views) and x2y3z4a5b6c7 (staging pg_cron matview refresh). Add a merge revision so `alembic upgrade head` resolves to a single head. Fixes the bdd-tests CI failure. Co-Authored-By: Claude Opus 4.8 --- ...erge_nmw_mirror_chain_into_staging_head.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 alembic/versions/03ef547ed7be_merge_nmw_mirror_chain_into_staging_head.py diff --git a/alembic/versions/03ef547ed7be_merge_nmw_mirror_chain_into_staging_head.py b/alembic/versions/03ef547ed7be_merge_nmw_mirror_chain_into_staging_head.py new file mode 100644 index 000000000..57bbd0909 --- /dev/null +++ b/alembic/versions/03ef547ed7be_merge_nmw_mirror_chain_into_staging_head.py @@ -0,0 +1,26 @@ +"""merge nmw mirror chain into staging head + +Revision ID: 03ef547ed7be +Revises: e2f3a4b5c6d7, x2y3z4a5b6c7 +Create Date: 2026-06-23 19:39:25.256933 + +""" + +from typing import Sequence, Union + + +# revision identifiers, used by Alembic. +revision: str = "03ef547ed7be" +down_revision: Union[str, Sequence[str], None] = ("e2f3a4b5c6d7", "x2y3z4a5b6c7") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass From e44dba66cc05f4d08be0e51100e4ebfbb8a31c4b Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:40:37 +0000 Subject: [PATCH 099/160] Formatting changes --- .../03ef547ed7be_merge_nmw_mirror_chain_into_staging_head.py | 1 - 1 file changed, 1 deletion(-) diff --git a/alembic/versions/03ef547ed7be_merge_nmw_mirror_chain_into_staging_head.py b/alembic/versions/03ef547ed7be_merge_nmw_mirror_chain_into_staging_head.py index 57bbd0909..42da6e150 100644 --- a/alembic/versions/03ef547ed7be_merge_nmw_mirror_chain_into_staging_head.py +++ b/alembic/versions/03ef547ed7be_merge_nmw_mirror_chain_into_staging_head.py @@ -8,7 +8,6 @@ from typing import Sequence, Union - # revision identifiers, used by Alembic. revision: str = "03ef547ed7be" down_revision: Union[str, Sequence[str], None] = ("e2f3a4b5c6d7", "x2y3z4a5b6c7") From a26b7f392ae2dcf015f910d9e7723d128d03c4df Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 23 Jun 2026 19:41:23 -0600 Subject: [PATCH 100/160] refactor(alembic): linearize nmw mirror chain onto staging head Replace the merge revision (03ef547ed7be) with a linear history: repoint c0d1e2f3a4b5.down_revision from the old shared base t6u7v8w9x0y1 to staging's head x2y3z4a5b6c7. Single head e2f3a4b5c6d7; no merge point. Co-Authored-By: Claude Opus 4.8 --- SPEC.md | 13 +++++++++- ...erge_nmw_mirror_chain_into_staging_head.py | 25 ------------------- .../c0d1e2f3a4b5_nmw_mirror_tables.py | 2 +- 3 files changed, 13 insertions(+), 27 deletions(-) delete mode 100644 alembic/versions/03ef547ed7be_merge_nmw_mirror_chain_into_staging_head.py diff --git a/SPEC.md b/SPEC.md index 18a722ed1..35919cbeb 100644 --- a/SPEC.md +++ b/SPEC.md @@ -24,10 +24,11 @@ Plus geothermal OGC API layers over mirror data. Phase-1 only: faithful copy, no - I.env — `NMW_SQL_DUMP` (dump path; else CSV), `NMW_CSV_DIR`, `TRANSFER_LIMIT`, `TRANSFER_GEOTHERMAL_REFERENCE` (def 1), `TRANSFER_NMW_MIRROR` (def 1). Export: `NMW_HOST/USER/PASSWORD/PORT/DATABASE`. - I.export — `transfers/export_nmw_csvs.py` — pymssql dump SQL Server → `transfers/data/nma_csv_cache/
.csv`. - I.db — 18 `NMW_*` mirror tables (`db/nmw_legacy.py`), PK verified vs dump DDL. -- I.migrations — `c0d1e2f3a4b5` (tables+FK), `d1e2f3a4b5c6` (per-well views), `e2f3a4b5c6d7` (measurement views). Chain down_rev: t6u7v8w9x0y1 → c0 → d1 → e2. +- I.migrations — `c0d1e2f3a4b5` (tables+FK), `d1e2f3a4b5c6` (per-well views), `e2f3a4b5c6d7` (measurement views). Linear chain (rebased onto staging head): x2y3z4a5b6c7 → c0 → d1 → e2. - I.ogc — 6 new collections in `core/pygeoapi-config.yml`: geothermal_wells_bht, geothermal_wells_temperature_profile (MATVIEW), bht_measurements, temp_depth_measurements, heat_flow, dst. - I.views — DB: ogc_geothermal_wells_bht, ogc_geothermal_wells_temperature_profile (MAT), ogc_geothermal_wells_summary_heat_flow, ogc_geothermal_wells_interval_heat_flow, ogc_bht_measurements, ogc_temp_depth_measurements, ogc_heat_flow, ogc_dst. - I.lexicon — `reference_lexicon_transfer.py` maps 46 `ref_*` → `nmw_ref_*` LexiconCategory + terms. +- I.jira — BDMS-826 "Geothermal Migration Planning" (Story, In Progress) under epic BDMS-843 "Geothermal Migration". 6 linked tasks tracked in §T (T18-T23). ## §V — invariants @@ -64,6 +65,16 @@ T15|.|finish PR #738 body (truncated at "- I ") + reviewer notes|- T16|.|P2 (later): transform NMW_* → Ocotillo model; build new enums for 11 LEXICON_CANDIDATES_NO_REF|C2,V9 T17|x|landed docs/nm_wells-migration.md (commit ccf566d9; force-add, docs/ gitignored). Referenced 4x: nmw_legacy.py:75,81,759; nmw_mirror_transfer.py:19|- +### BDMS-826 linked tasks (six; status mirrors Jira) + +id|status|task|cites +T18|x|BDMS-827 read/review Geothermal Data Discovery Report (Jira Done)|I.jira +T19|x|BDMS-846 technical review complete (Jira Done)|I.jira +T20|x|BDMS-845 Geothermal Report finalized (Jira Done)|I.jira +T21|x|BDMS-847 update data-migration tracking mechanism (Jira Done)|I.jira +T22|x|BDMS-907 stakeholder engagement follow-up (Jira Done; blocks BDMS-826)|I.jira +T23|~|BDMS-848 Geothermal Migration Technical Implementation Plan (Jira In Progress) — umbrella for code tasks T1-T16|I.jira,T1,T16 + ## §B — bugs id|date|cause|fix diff --git a/alembic/versions/03ef547ed7be_merge_nmw_mirror_chain_into_staging_head.py b/alembic/versions/03ef547ed7be_merge_nmw_mirror_chain_into_staging_head.py deleted file mode 100644 index 42da6e150..000000000 --- a/alembic/versions/03ef547ed7be_merge_nmw_mirror_chain_into_staging_head.py +++ /dev/null @@ -1,25 +0,0 @@ -"""merge nmw mirror chain into staging head - -Revision ID: 03ef547ed7be -Revises: e2f3a4b5c6d7, x2y3z4a5b6c7 -Create Date: 2026-06-23 19:39:25.256933 - -""" - -from typing import Sequence, Union - -# revision identifiers, used by Alembic. -revision: str = "03ef547ed7be" -down_revision: Union[str, Sequence[str], None] = ("e2f3a4b5c6d7", "x2y3z4a5b6c7") -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - pass - - -def downgrade() -> None: - """Downgrade schema.""" - pass diff --git a/alembic/versions/c0d1e2f3a4b5_nmw_mirror_tables.py b/alembic/versions/c0d1e2f3a4b5_nmw_mirror_tables.py index 42a8cc8ba..f59a760ca 100644 --- a/alembic/versions/c0d1e2f3a4b5_nmw_mirror_tables.py +++ b/alembic/versions/c0d1e2f3a4b5_nmw_mirror_tables.py @@ -44,7 +44,7 @@ from sqlalchemy.dialects import postgresql revision: str = "c0d1e2f3a4b5" -down_revision: Union[str, Sequence[str], None] = "t6u7v8w9x0y1" +down_revision: Union[str, Sequence[str], None] = "x2y3z4a5b6c7" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None From 25db7345ff0ee9fd74816569f61164bbfb418932 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 23 Jun 2026 19:46:21 -0600 Subject: [PATCH 101/160] docs(spec): verify all NM_Wells Migrate-First tables mirrored (V12) Cross-checked NMW_MIRROR_SPECS against the planning workbook: all 18 NM_Wells "Migrate First" tables are handled. Flagged 4 Subsurface Library "Migrate First" tables (dst_scan, log_scanned, Well_Header, well_operators) as out-of-scope (separate source DB) under new task T24. Co-Authored-By: Claude Opus 4.8 --- SPEC.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SPEC.md b/SPEC.md index 35919cbeb..048c4bc1f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -43,6 +43,7 @@ Plus geothermal OGC API layers over mirror data. Phase-1 only: faithful copy, no - V9. P2 lexicon mapping complete: every coded NMW col either in `LEXICON_REF_BY_COLUMN` (28, has ref_*) or `LEXICON_CANDIDATES_NO_REF` (11, needs new enum). - V10. ORM `NMW_*` models declare index only (`index=True`, no ORM `ForeignKey`); FK enforcement lives in migration `c0d1e2f3a4b5` (`op.create_foreign_key`). Keep both in sync. - V11. SQL-dump parser unwraps `CAST(expr AS )` for parameterised types too (`Decimal(18,2)`, `nvarchar(10)`); never store the literal `CAST(...)` string in a mirror column. +- V12. Every "Migrate First" table in the NM_Wells inventory (planning workbook) has a `NMW_*` mirror in `NMW_MIRROR_SPECS`. Verified 18/18 (2026-06-23). Subsurface Library is a separate source DB — NOT covered by this invariant (see T24). ## §T — tasks @@ -63,6 +64,7 @@ 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|- T16|.|P2 (later): transform NMW_* → Ocotillo model; build new enums for 11 LEXICON_CANDIDATES_NO_REF|C2,V9 +T24|.|Subsurface Library "Migrate First" tables NOT mirrored (separate source DB, out of NM_Wells scope): dst_scan, log_scanned, Well_Header, well_operators. Workbook lacks field map/DDL — needs own ticket|V12 T17|x|landed docs/nm_wells-migration.md (commit ccf566d9; force-add, docs/ gitignored). Referenced 4x: nmw_legacy.py:75,81,759; nmw_mirror_transfer.py:19|- ### BDMS-826 linked tasks (six; status mirrors Jira) From f4c805f8a1c18aec694af3e8c9b07f46ffbf4227 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 23 Jun 2026 19:51:53 -0600 Subject: [PATCH 102/160] fix(nmw): FK-safe truncate + dedup locations in per-well OGC views Addresses PR #740 Codex review: - P1 (B2/V13): dump-load reload used a bare TRUNCATE, rejected by the FK refs to NMW_WellHeaders. Use TRUNCATE ... CASCADE (parents load before children per V2, so cascaded children are reloaded after). - P2 (B3/V14): per-well geothermal views joined NMW_WellLocations directly; multiple OBJECTID rows per WellDataID inflated counts / emitted >1 feature per well. Dedup via DISTINCT ON loc CTE in all 4 views (bht, summary and interval heat-flow; profile already did this). Adds two regression tests. 21 passing. Co-Authored-By: Claude Opus 4.8 --- SPEC.md | 4 + ...4b5c6_nmw_per_well_geothermal_ogc_views.py | 68 +++++++++++---- tests/test_nmw_mirror.py | 86 +++++++++++++++++++ transfers/nmw_mirror_transfer.py | 6 +- 4 files changed, 146 insertions(+), 18 deletions(-) diff --git a/SPEC.md b/SPEC.md index 048c4bc1f..3731fa02e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -44,6 +44,8 @@ Plus geothermal OGC API layers over mirror data. Phase-1 only: faithful copy, no - V10. ORM `NMW_*` models declare index only (`index=True`, no ORM `ForeignKey`); FK enforcement lives in migration `c0d1e2f3a4b5` (`op.create_foreign_key`). Keep both in sync. - V11. SQL-dump parser unwraps `CAST(expr AS )` for parameterised types too (`Decimal(18,2)`, `nvarchar(10)`); never store the literal `CAST(...)` string in a mirror column. - V12. Every "Migrate First" table in the NM_Wells inventory (planning workbook) has a `NMW_*` mirror in `NMW_MIRROR_SPECS`. Verified 18/18 (2026-06-23). Subsurface Library is a separate source DB — NOT covered by this invariant (see T24). +- V13. Mirror dump-reload truncates with `CASCADE` (mirror tables carry FK constraints; bare TRUNCATE of a referenced parent is rejected). Safe because parents load before children (V2). +- V14. Per-well OGC views dedup `NMW_WellLocations` by `WellDataID` (`DISTINCT ON ... ORDER BY "WellDataID","OBJECTID"`); one feature per well, counts not multiplied by duplicate location rows. Applies to all 4 per-well geothermal views. ## §T — tasks @@ -81,3 +83,5 @@ T23|~|BDMS-848 Geothermal Migration Technical Implementation Plan (Jira In Progr id|date|cause|fix B1|2026-06-23|_CAST_RE in transfers/nmw_sql_dump.py matched AS-type without parens only; parenthesised types (nvarchar(10), Decimal(18,2)) left value as literal "CAST(...)" string|V11; widened regex to allow one paren level +B2|2026-06-23|dump-load reload used bare TRUNCATE; FK from NMW_WellLocations/NMW_WellRecords to NMW_WellHeaders makes Postgres reject it, aborting load before COPY (PR#740 P1)|V13; TRUNCATE ... CASCADE +B3|2026-06-23|per-well geothermal OGC views (bht, summary_heat_flow, interval_heat_flow) joined NMW_WellLocations directly; multiple OBJECTID rows per WellDataID multiplied counts / emitted >1 feature per well (PR#740 P2)|V14; DISTINCT ON loc CTE in all 4 views diff --git a/alembic/versions/d1e2f3a4b5c6_nmw_per_well_geothermal_ogc_views.py b/alembic/versions/d1e2f3a4b5c6_nmw_per_well_geothermal_ogc_views.py index 0931b3596..e2cf47df4 100644 --- a/alembic/versions/d1e2f3a4b5c6_nmw_per_well_geothermal_ogc_views.py +++ b/alembic/versions/d1e2f3a4b5c6_nmw_per_well_geothermal_ogc_views.py @@ -48,8 +48,18 @@ def upgrade() -> None: # 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 + 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" + ) SELECT row_number() OVER () AS id, r."WellDataID"::text AS well_data_id, @@ -68,10 +78,8 @@ def upgrade() -> None: 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 "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + JOIN loc ON loc."WellDataID" = r."WellDataID" LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" - WHERE loc."Lat_dd83" IS NOT NULL - AND loc."Long_dd83" IS NOT NULL GROUP BY r."WellDataID", loc."Lat_dd83", @@ -79,11 +87,15 @@ 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 WITH loc AS ( SELECT DISTINCT ON ("WellDataID") @@ -124,7 +136,9 @@ 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)') ) @@ -136,8 +150,18 @@ def upgrade() -> None: # ogc_geothermal_wells_summary_heat_flow op.execute(text(f'DROP VIEW IF EXISTS "{_SUM_HF_VIEW}"')) - op.execute(text(f""" + op.execute( + text( + f""" CREATE VIEW "{_SUM_HF_VIEW}" AS + 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" + ) SELECT row_number() OVER () AS id, r."WellDataID"::text AS well_data_id, @@ -173,22 +197,32 @@ def upgrade() -> None: ) AS geom FROM "NMW_GtSumHeatFlow" AS shf JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = shf."RecrdSetID" - JOIN "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + JOIN loc ON loc."WellDataID" = r."WellDataID" LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" - WHERE loc."Lat_dd83" IS NOT NULL - AND loc."Long_dd83" IS NOT NULL GROUP BY r."WellDataID", loc."Lat_dd83", loc."Long_dd83", hdr."CurWellNam", hdr."API" - """)) + """ + ) + ) # ogc_geothermal_wells_interval_heat_flow op.execute(text(f'DROP VIEW IF EXISTS "{_INT_HF_VIEW}"')) - op.execute(text(f""" + op.execute( + text( + f""" CREATE VIEW "{_INT_HF_VIEW}" AS + 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" + ) SELECT row_number() OVER () AS id, r."WellDataID"::text AS well_data_id, @@ -224,17 +258,17 @@ def upgrade() -> None: JOIN "NMW_WsIntervals" AS i ON i."IntrvlGUID" = hf."IntrvlGUID" JOIN "NMW_WellSamples" AS s ON s."SamplSetID" = i."SamplSetID" JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = s."RecrdsetID" - JOIN "NMW_WellLocations" AS loc ON loc."WellDataID" = r."WellDataID" + JOIN loc ON loc."WellDataID" = r."WellDataID" LEFT JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" - WHERE loc."Lat_dd83" IS NOT NULL - AND loc."Long_dd83" IS NOT NULL GROUP BY r."WellDataID", loc."Lat_dd83", loc."Long_dd83", hdr."CurWellNam", hdr."API" - """)) + """ + ) + ) def downgrade() -> None: diff --git a/tests/test_nmw_mirror.py b/tests/test_nmw_mirror.py index 2756172b5..a6a785867 100644 --- a/tests/test_nmw_mirror.py +++ b/tests/test_nmw_mirror.py @@ -240,4 +240,90 @@ def test_iter_table_rows_parses_inserts(tmp_path): ] +# ------------------------------------------------------ regression: FK truncate +def test_truncate_referenced_parent_needs_cascade(): + """A bare TRUNCATE of an FK-referenced mirror parent is rejected; the loader + must use CASCADE (V13 / B2). Guards the dump-load reload path.""" + import sqlalchemy.exc + + # Bare truncate of the FK-referenced parent should error... + with session_ctx() as session: + with pytest.raises(sqlalchemy.exc.DBAPIError): + session.execute(text('TRUNCATE TABLE "NMW_WellHeaders"')) + # ...while CASCADE (what the loader does) succeeds. + with session_ctx() as session: + session.execute(text('TRUNCATE TABLE "NMW_WellHeaders" CASCADE')) + session.commit() + + +# --------------------------------------------- regression: one feature per well +def test_bht_view_one_feature_per_well_with_duplicate_locations(): + """Two location rows for one WellDataID must not multiply BHT features or + inflate bht_count; the view dedups locations via DISTINCT ON (V14 / B3).""" + wid = "11111111-1111-1111-1111-111111111111" + rsid = "22222222-2222-2222-2222-222222222222" + ssid = "33333333-3333-3333-3333-333333333333" + bht = "44444444-4444-4444-4444-444444444444" + with session_ctx() as session: + session.execute( + text('INSERT INTO "NMW_WellHeaders" ("WellDataID") VALUES (:w)'), + {"w": wid}, + ) + # TWO location rows, same WellDataID, valid coords (the bug trigger). + session.execute( + text( + 'INSERT INTO "NMW_WellLocations" ' + '("OBJECTID","WellDataID","Lat_dd83","Long_dd83") VALUES ' + "(901,:w,33.0,-107.0),(902,:w,33.0,-107.0)" + ), + {"w": wid}, + ) + session.execute( + text( + 'INSERT INTO "NMW_WellRecords" ("RecrdSetID","WellDataID") ' + "VALUES (:r,:w)" + ), + {"r": rsid, "w": wid}, + ) + session.execute( + text( + 'INSERT INTO "NMW_WellSamples" ("SamplSetID","RecrdsetID") ' + "VALUES (:s,:r)" + ), + {"s": ssid, "r": rsid}, + ) + session.execute( + text( + 'INSERT INTO "NMW_GtBhtHeaders" ("BHTGUID","SamplSetID") ' + "VALUES (:b,:s)" + ), + {"b": bht, "s": ssid}, + ) + session.execute( + text( + 'INSERT INTO "NMW_GtBhtData" ("OBJECTID","BHTGUID","BHT","Depth") ' + "VALUES (911,:b,150.0,1000.0)" + ), + {"b": bht}, + ) + session.commit() + try: + with session_ctx() as session: + rows = session.execute( + text( + 'SELECT bht_count FROM "ogc_geothermal_wells_bht" ' + "WHERE well_data_id = :w" + ), + {"w": wid}, + ).all() + assert len(rows) == 1, f"expected one feature per well, got {len(rows)}" + assert ( + rows[0][0] == 1 + ), f"bht_count inflated by duplicate locations: {rows[0][0]}" + finally: + with session_ctx() as session: + session.execute(text('TRUNCATE TABLE "NMW_WellHeaders" CASCADE')) + session.commit() + + # ============= EOF ============================================= diff --git a/transfers/nmw_mirror_transfer.py b/transfers/nmw_mirror_transfer.py index 28f78945a..d59ef4eaf 100644 --- a/transfers/nmw_mirror_transfer.py +++ b/transfers/nmw_mirror_transfer.py @@ -237,7 +237,11 @@ def _copy_load_table( return {"table": name, "skipped": True, "reason": "no rows", "source": "sql"} # Staging reload: truncate then COPY (no upsert; tables are a 1:1 snapshot). - session.execute(text(f'TRUNCATE TABLE "{table.name}"')) + # CASCADE because mirror tables carry FK constraints (e.g. NMW_WellLocations + # / NMW_WellRecords -> NMW_WellHeaders); a bare TRUNCATE of a referenced + # parent is rejected. Specs load parents before children (see V2), so a + # cascaded truncate only clears child tables that are reloaded afterwards. + session.execute(text(f'TRUNCATE TABLE "{table.name}" CASCADE')) _copy_csv_into_table(session, table.name, header, out_csv) session.commit() logger.info("COPY %s -> %s: %d rows (%s)", name, table.name, n, out_csv) From 2a4ffb2332818f4aeaa89a59e6d1ad1a6e8f5bee Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:52:13 +0000 Subject: [PATCH 103/160] Formatting changes --- ...4b5c6_nmw_per_well_geothermal_ogc_views.py | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/alembic/versions/d1e2f3a4b5c6_nmw_per_well_geothermal_ogc_views.py b/alembic/versions/d1e2f3a4b5c6_nmw_per_well_geothermal_ogc_views.py index e2cf47df4..696b8118a 100644 --- a/alembic/versions/d1e2f3a4b5c6_nmw_per_well_geothermal_ogc_views.py +++ b/alembic/versions/d1e2f3a4b5c6_nmw_per_well_geothermal_ogc_views.py @@ -48,9 +48,7 @@ def upgrade() -> None: # 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 WITH loc AS ( SELECT DISTINCT ON ("WellDataID") @@ -87,15 +85,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 WITH loc AS ( SELECT DISTINCT ON ("WellDataID") @@ -136,9 +130,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)') ) @@ -150,9 +142,7 @@ def upgrade() -> None: # ogc_geothermal_wells_summary_heat_flow op.execute(text(f'DROP VIEW IF EXISTS "{_SUM_HF_VIEW}"')) - op.execute( - text( - f""" + op.execute(text(f""" CREATE VIEW "{_SUM_HF_VIEW}" AS WITH loc AS ( SELECT DISTINCT ON ("WellDataID") @@ -205,15 +195,11 @@ def upgrade() -> None: loc."Long_dd83", hdr."CurWellNam", hdr."API" - """ - ) - ) + """)) # ogc_geothermal_wells_interval_heat_flow op.execute(text(f'DROP VIEW IF EXISTS "{_INT_HF_VIEW}"')) - op.execute( - text( - f""" + op.execute(text(f""" CREATE VIEW "{_INT_HF_VIEW}" AS WITH loc AS ( SELECT DISTINCT ON ("WellDataID") @@ -266,9 +252,7 @@ def upgrade() -> None: loc."Long_dd83", hdr."CurWellNam", hdr."API" - """ - ) - ) + """)) def downgrade() -> None: From 8aa89c141405f14de5d3251377530a6d0e5a6b94 Mon Sep 17 00:00:00 2001 From: jakeross Date: Wed, 24 Jun 2026 07:55:21 -0600 Subject: [PATCH 104/160] docs(nmw): add NM_Wells mirror transfer + verification runbook Operational steps to run the Phase-1 NM_Wells 1:1 mirror (export, load, refresh) and verify it: row-count parity, FK orphan checks, OGC view/API checks, reversible migrations. Maps sign-off to BDMS-969/951/954 and documents the B1/B2/B3 fixes in troubleshooting. docs/ is gitignored; force-added. Co-Authored-By: Claude Opus 4.8 --- docs/nm_wells-transfer-runbook.md | 248 ++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/nm_wells-transfer-runbook.md diff --git a/docs/nm_wells-transfer-runbook.md b/docs/nm_wells-transfer-runbook.md new file mode 100644 index 000000000..b1168f2ac --- /dev/null +++ b/docs/nm_wells-transfer-runbook.md @@ -0,0 +1,248 @@ +# NM_Wells 1:1 Mirror Transfer — Runbook + +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. + +- Code: `transfers/transfer_geothermal.py` (orchestrator), `transfers/nmw_mirror_transfer.py` + (loader), `transfers/export_nmw_csvs.py` (CSV export), `transfers/nmw_sql_dump.py` (dump parser). +- Models: `db/nmw_legacy.py` (18 `NMW_*` tables). +- Design notes: [docs/nm_wells-migration.md](nm_wells-migration.md). +- Jira: [BDMS-945](https://nmbgmr.atlassian.net/browse/BDMS-945) (story), + [BDMS-969](https://nmbgmr.atlassian.net/browse/BDMS-969) (this e2e run), + [BDMS-970](https://nmbgmr.atlassian.net/browse/BDMS-970) (SQL Server access — blocker). + +--- + +## 0. Prerequisites + +- [ ] **SQL Server access** ([BDMS-970](https://nmbgmr.atlassian.net/browse/BDMS-970)): + password reset done, can reach the NM_Wells host (Argon / Agustin / Sediment / + SQL dev / SQLServer2019 as applicable). +- [ ] Python env ready: `uv venv && source .venv/bin/activate && uv sync --locked`. +- [ ] Target Postgres + PostGIS reachable; `.env` has `POSTGRES_*` (or Cloud SQL) creds. +- [ ] `.env` has the SQL Server source creds (only needed for the live export, step 1): + +```bash +NMW_HOST= +NMW_PORT=1433 +NMW_USER= +NMW_PASSWORD= +NMW_DATABASE=NM_Wells +``` + +Pick **one** row source for the load: + +| Source | When | Set | +|--------|------|-----| +| Per-table CSVs | live export via pymssql (default path) | nothing (`NMW_SQL_DUMP` unset) | +| SQL dump `.sql` | you have an SSMS data dump | `NMW_SQL_DUMP=/path/to/dump.sql` | + +--- + +## 1. Apply schema (migrations) + +The transfer assumes the schema already exists — it does not create/drop tables. + +```bash +alembic upgrade head +``` + +Creates the 18 `NMW_*` tables + FKs and the 8 OGC backing views. Migration chain: +`c0d1e2f3a4b5` (tables+FK) → `d1e2f3a4b5c6` (per-well views) → `e2f3a4b5c6d7` (measurement views). + +Verify the tables and views exist: + +```bash +psql "$DATABASE_URL" -c '\dt "NMW_*"' # expect 18 tables +psql "$DATABASE_URL" -c '\dv ogc_*' # ogc_* views +psql "$DATABASE_URL" -c '\dm ogc_*' # matview: ogc_geothermal_wells_temperature_profile +``` + +--- + +## 2. Export source tables to CSV (live source) + +Skip if you're loading from a `.sql` dump (`NMW_SQL_DUMP` set). + +```bash +uv run python -m transfers.export_nmw_csvs +``` + +- Writes `transfers/data/nma_csv_cache/
.csv`, one per mirrored table. +- Prints per-table row counts — **record these**; they are the source-of-truth counts for + the post-load comparison in step 4. +- Any `FAILED: ...` line means that table didn't export — investigate before loading. + +--- + +## 3. Run the transfer + +Smoke-test with a row cap first, then run the full load. + +```bash +# Smoke test: 1000 rows/table +TRANSFER_LIMIT=1000 uv run python -m transfers.transfer_geothermal + +# Full load (all rows) +uv run python -m transfers.transfer_geothermal +``` + +Relevant env (all optional, sane defaults): + +| Var | Default | Effect | +|-----|---------|--------| +| `TRANSFER_LIMIT` | 0 (all) | rows per table | +| `NMW_SQL_DUMP` | unset | load from `.sql` dump instead of CSVs | +| `NMW_CSV_DIR` | temp dir | where dump-derived CSVs are written (dump path) | +| `TRANSFER_GEOTHERMAL_REFERENCE` | 1 | load `ref_*` → lexicon | +| `TRANSFER_NMW_MIRROR` | 1 | load `NMW_*` mirror + refresh matviews | + +The orchestrator: loads reference→lexicon, loads the mirror parent→child in FK order, then +refreshes the materialized OGC view. It prints a summary dict — confirm +`mirror.errors == 0` and `reference.errors == 0`. + +Re-running is safe: dump path is truncate+COPY (CASCADE), CSV path is +`INSERT ... ON CONFLICT DO NOTHING`. No duplicate rows. + +--- + +## 4. Verify — row counts + +Compare each mirror table's row count against the source counts captured in step 2 +(or against SQL Server directly). + +```bash +psql "$DATABASE_URL" <<'SQL' +SELECT 'NMW_WellHeaders' AS t, count(*) FROM "NMW_WellHeaders" +UNION ALL SELECT 'NMW_WellLocations', count(*) FROM "NMW_WellLocations" +UNION ALL SELECT 'NMW_WellRecords', count(*) FROM "NMW_WellRecords" +UNION ALL SELECT 'NMW_WellSamples', count(*) FROM "NMW_WellSamples" +UNION ALL SELECT 'NMW_WellZDatum', count(*) FROM "NMW_WellZDatum" +UNION ALL SELECT 'NMW_Sources', count(*) FROM "NMW_Sources" +UNION ALL SELECT 'NMW_GtBhtHeaders', count(*) FROM "NMW_GtBhtHeaders" +UNION ALL SELECT 'NMW_GtBhtData', count(*) FROM "NMW_GtBhtData" +UNION ALL SELECT 'NMW_GtTempDepths', count(*) FROM "NMW_GtTempDepths" +UNION ALL SELECT 'NMW_GtConductivity', count(*) FROM "NMW_GtConductivity" +UNION ALL SELECT 'NMW_GtHeatFlow', count(*) FROM "NMW_GtHeatFlow" +UNION ALL SELECT 'NMW_GtSumHeatFlow', count(*) FROM "NMW_GtSumHeatFlow" +UNION ALL SELECT 'NMW_WsDstHeaders', count(*) FROM "NMW_WsDstHeaders" +UNION ALL SELECT 'NMW_WsDstIntervals', count(*) FROM "NMW_WsDstIntervals" +UNION ALL SELECT 'NMW_WsDstFlowHistory', count(*) FROM "NMW_WsDstFlowHistory" +UNION ALL SELECT 'NMW_WsDstFluidProperties', count(*) FROM "NMW_WsDstFluidProperties" +UNION ALL SELECT 'NMW_WsDstPressure', count(*) FROM "NMW_WsDstPressure" +UNION ALL SELECT 'NMW_WsIntervals', count(*) FROM "NMW_WsIntervals" +ORDER BY t; +SQL +``` + +**Pass:** every count matches source (or matches `TRANSFER_LIMIT` if capped). Note any table +where the count is 0 or short — likely a failed export or an FK-skipped child row. + +--- + +## 5. Verify — FK integrity + +No child row should reference a missing parent. Spot-check the main hierarchy +(`NMW_WellHeaders` is the root parent): + +```bash +psql "$DATABASE_URL" <<'SQL' +-- locations / records with no matching well header (expect 0) +SELECT 'orphan_locations' AS check, count(*) +FROM "NMW_WellLocations" l +LEFT JOIN "NMW_WellHeaders" h ON l."WellDataID" = h."WellDataID" +WHERE h."WellDataID" IS NULL +UNION ALL +SELECT 'orphan_records', count(*) +FROM "NMW_WellRecords" r +LEFT JOIN "NMW_WellHeaders" h ON r."WellDataID" = h."WellDataID" +WHERE h."WellDataID" IS NULL; +SQL +``` + +**Pass:** both counts are 0. (FK constraints are enforced at load, so a non-zero here means +data was loaded out of order or a constraint is missing — investigate.) + +--- + +## 6. Verify — OGC views + matview + +Refresh happens automatically in step 3. To refresh manually: + +```bash +psql "$DATABASE_URL" -c 'REFRESH MATERIALIZED VIEW ogc_geothermal_wells_temperature_profile;' +``` + +Confirm each backing view returns rows and the per-well views emit **one feature per well** +(no count multiplication from duplicate location rows): + +```bash +psql "$DATABASE_URL" <<'SQL' +SELECT 'ogc_geothermal_wells_bht' AS v, count(*) FROM ogc_geothermal_wells_bht +UNION ALL SELECT 'ogc_geothermal_wells_temperature_profile', count(*) FROM ogc_geothermal_wells_temperature_profile +UNION ALL SELECT 'ogc_geothermal_wells_summary_heat_flow', count(*) FROM ogc_geothermal_wells_summary_heat_flow +UNION ALL SELECT 'ogc_geothermal_wells_interval_heat_flow', count(*) FROM ogc_geothermal_wells_interval_heat_flow +UNION ALL SELECT 'ogc_bht_measurements', count(*) FROM ogc_bht_measurements +UNION ALL SELECT 'ogc_temp_depth_measurements',count(*) FROM ogc_temp_depth_measurements +UNION ALL SELECT 'ogc_heat_flow', count(*) FROM ogc_heat_flow +UNION ALL SELECT 'ogc_dst', count(*) FROM ogc_dst; +SQL +``` + +Then hit the OGC API (with the app running) — all 6 collections should resolve and return +GeoJSON features: + +```bash +for c in geothermal_wells_bht geothermal_wells_temperature_profile \ + bht_measurements temp_depth_measurements heat_flow dst; do + echo "== $c ==" + curl -s "http://localhost:8000/ogcapi/collections/$c/items?limit=1" | head -c 400 + echo +done +``` + +**Pass:** each returns HTTP 200 with a `FeatureCollection`; per-well collections show +distinct wells (no duplicate `WellDataID`). + +--- + +## 7. Verify — migrations reversible (non-prod only) + +On a scratch/test DB, confirm a clean down/up cycle drops and recreates all 18 tables + 8 +views with no orphans: + +```bash +alembic downgrade base +psql "$DATABASE_URL" -c '\dt "NMW_*"' # expect 0 +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`. + +--- + +## Sign-off checklist (closes BDMS-969 → unblocks BDMS-951 / BDMS-954) + +- [ ] Schema applied; 18 tables + 8 views present (step 1). +- [ ] Source CSVs exported; per-table source counts recorded (step 2). +- [ ] Transfer ran with `errors == 0` (step 3). +- [ ] Row counts match source (step 4). +- [ ] No orphan FK rows (step 5). +- [ ] All 6 OGC collections resolve; one feature per well (step 6). +- [ ] Migrations down/up clean on scratch DB (step 7). + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| `export_nmw_csvs` connection refused | SQL Server access not granted | [BDMS-970](https://nmbgmr.atlassian.net/browse/BDMS-970); check `NMW_HOST/PORT`, VPN | +| `TRUNCATE ... cannot truncate a table referenced in a foreign key` | parent truncated before child | loader uses `TRUNCATE ... CASCADE` (B2); confirm you're on current branch | +| Mirror column holds literal `CAST(...)` string | dump parser missed a parameterised type | fixed in `nmw_sql_dump.py` (B1); confirm branch is current | +| Per-well OGC view count > # wells | duplicate `NMW_WellLocations` rows | views dedup via `DISTINCT ON (WellDataID)` (B3); confirm branch is current | +| matview empty / stale | refresh skipped | `REFRESH MATERIALIZED VIEW ogc_geothermal_wells_temperature_profile;` | +| child table row count short | FK-skipped rows (`ON CONFLICT`/missing parent) | check parent loaded first; re-run full load | From 07cba37fc578f26bf59999a9a31794a05d7ed925 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 24 Jun 2026 10:02:48 -0500 Subject: [PATCH 105/160] fix(core/dependencies): Corrected types & improved core route guard --- core/app.py | 3 ++- core/dependencies.py | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/core/app.py b/core/app.py index 17c04484c..6ee7ad992 100644 --- a/core/app.py +++ b/core/app.py @@ -128,7 +128,8 @@ def public_openapi(): ( r for r in app.routes - if r.path == path and method.upper() in r.methods + if getattr(r, "path", None) == path + and method.upper() in getattr(r, "methods", set()) ), None, ) diff --git a/core/dependencies.py b/core/dependencies.py index 98cfbfe64..8d4aa354d 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from typing import Annotated +from typing import Annotated, TypeAlias from fastapi import Depends from sqlalchemy.orm import Session @@ -21,7 +21,7 @@ from core.permissions import authenticated from db.engine import get_db_session -session_dependency: type[Session] = Annotated[Session, Depends(get_db_session)] +session_dependency: TypeAlias = Annotated[Session, Depends(get_db_session)] """ Developer Notes @@ -61,18 +61,18 @@ # Permissions Dependencies ----------------------------------------------------- -admin_dependency: type[dict] = Annotated[dict, Depends(admin_function)] -editor_dependency: type[dict] = Annotated[dict, Depends(editor_function)] -viewer_dependency: type[dict] = Annotated[dict, Depends(viewer_function)] +admin_dependency: TypeAlias = Annotated[dict, Depends(admin_function)] +editor_dependency: TypeAlias = Annotated[dict, Depends(editor_function)] +viewer_dependency: TypeAlias = Annotated[dict, Depends(viewer_function)] -lexicon_admin_dependency: type[dict] = Annotated[dict, Depends(lexicon_admin_function)] -lexicon_editor_dependency: type[dict] = Annotated[ +lexicon_admin_dependency: TypeAlias = Annotated[dict, Depends(lexicon_admin_function)] +lexicon_editor_dependency: TypeAlias = Annotated[ dict, Depends(lexicon_editor_function) ] -amp_admin_dependency: type[dict] = Annotated[dict, Depends(amp_admin_function)] -amp_editor_dependency: type[dict] = Annotated[dict, Depends(amp_editor_function)] -amp_viewer_dependency: type[dict] = Annotated[dict, Depends(amp_viewer_function)] +amp_admin_dependency: TypeAlias = Annotated[dict, Depends(amp_admin_function)] +amp_editor_dependency: TypeAlias = Annotated[dict, Depends(amp_editor_function)] +amp_viewer_dependency: TypeAlias = Annotated[dict, Depends(amp_viewer_function)] -no_permission_dependency: type[dict] = Annotated[dict, Depends(no_permission_function)] +no_permission_dependency: TypeAlias = Annotated[dict, Depends(no_permission_function)] # ============= EOF ============================================= From 5c3e8afdad55f56176a28ef4fe40c82cd3f82a4a Mon Sep 17 00:00:00 2001 From: TylerAdamMartinez <57375362+TylerAdamMartinez@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:04:55 +0000 Subject: [PATCH 106/160] Formatting changes --- core/dependencies.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/core/dependencies.py b/core/dependencies.py index 8d4aa354d..eabcd009a 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -66,9 +66,7 @@ viewer_dependency: TypeAlias = Annotated[dict, Depends(viewer_function)] lexicon_admin_dependency: TypeAlias = Annotated[dict, Depends(lexicon_admin_function)] -lexicon_editor_dependency: TypeAlias = Annotated[ - dict, Depends(lexicon_editor_function) -] +lexicon_editor_dependency: TypeAlias = Annotated[dict, Depends(lexicon_editor_function)] amp_admin_dependency: TypeAlias = Annotated[dict, Depends(amp_admin_function)] amp_editor_dependency: TypeAlias = Annotated[dict, Depends(amp_editor_function)] From f550ad954e9f8fbd8b1e8d0c7bab1906a5c9f428 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 24 Jun 2026 12:00:59 -0600 Subject: [PATCH 107/160] feat: allow configurable output directory for NMW CSV exports --- docs/nm_wells-transfer-runbook.md | 6 ++++++ transfers/export_nmw_csvs.py | 5 ++++- transfers/util.py | 5 +++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/nm_wells-transfer-runbook.md b/docs/nm_wells-transfer-runbook.md index b1168f2ac..60731a8b5 100644 --- a/docs/nm_wells-transfer-runbook.md +++ b/docs/nm_wells-transfer-runbook.md @@ -164,6 +164,12 @@ SQL **Pass:** both counts are 0. (FK constraints are enforced at load, so a non-zero here means data was loaded out of order or a constraint is missing — investigate.) +**Known exception:** `orphan_locations` reports **51** rows. These are `NMW_WellLocations` +rows whose `WellDataID` is blank in the source (`tbl_well_locations.csv`): empty values load +as NULL, NULL FK columns are exempt from FK enforcement, and the `LEFT JOIN ... IS NULL` +check counts them as orphans. This is a source data-quality issue, not a load-order or +constraint problem — accepted as-is. `orphan_records` must still be 0. + --- ## 6. Verify — OGC views + matview diff --git a/transfers/export_nmw_csvs.py b/transfers/export_nmw_csvs.py index e2cf438b3..51daf403e 100644 --- a/transfers/export_nmw_csvs.py +++ b/transfers/export_nmw_csvs.py @@ -27,7 +27,10 @@ TABLES = [spec.source_table for spec in NMW_MIRROR_SPECS] -OUT_DIR = Path(__file__).parent / "data" / "nma_csv_cache" +_data_root = os.environ.get("TRANSFERS_DATA_DIR") +OUT_DIR = ( + Path(_data_root) if _data_root else Path(__file__).parent / "data" / "nma_csv_cache" +) def _get_connection(): diff --git a/transfers/util.py b/transfers/util.py index 5fd1a4710..ff5c4f4e7 100644 --- a/transfers/util.py +++ b/transfers/util.py @@ -406,6 +406,11 @@ def extract_organization(alternate_id: str) -> str: def get_transfers_data_path(name: str) -> Path: + # Explicit override wins: CSVs live flat in this dir (no nma_csv_cache subdir). + env_root = os.environ.get("TRANSFERS_DATA_DIR") + if env_root: + return Path(env_root) / Path(name).name + def data_path(r): return Path(r) / "transfers" / "data" From 0db8c77604f4301d371a1146a8d7704907a42418 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 25 Jun 2026 14:11:52 -0500 Subject: [PATCH 108/160] fix(api/asset): Add delete asset notifications --- api/asset.py | 5 +---- tests/test_asset.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/api/asset.py b/api/asset.py index 0439b7b05..d7d2b62d6 100644 --- a/api/asset.py +++ b/api/asset.py @@ -554,10 +554,7 @@ async def update_asset( async def delete_asset( asset_id: int, session: session_dependency, user: admin_dependency ): - - # TODO: Interesting issue here. We don't have a way of tracking - # who deleted a record. - return model_deleter(session, Asset, asset_id) + return model_deleter(session, Asset, asset_id, user=user) @router.delete( diff --git a/tests/test_asset.py b/tests/test_asset.py index 158e53597..6266e7c72 100644 --- a/tests/test_asset.py +++ b/tests/test_asset.py @@ -475,6 +475,36 @@ def test_delete_asset(second_asset): assert data["detail"] == f"Asset with ID {second_asset.id} not found." +def test_delete_asset_notifies_slack(second_asset, monkeypatch): + calls: list[tuple[str, dict]] = [] + + def _capture(webhook_url: str, payload: dict) -> None: + calls.append((webhook_url, payload)) + + monkeypatch.setenv( + "SLACK_EDITS_WEBHOOK_URL", + "https://hooks.slack.test/edit", + ) + monkeypatch.setattr( + "services.edit_notification_helper._post_slack_async", + _capture, + ) + + response = client.delete(f"/asset/{second_asset.id}") + + assert response.status_code == 204 + assert len(calls) == 1 + assert calls[0][0] == "https://hooks.slack.test/edit" + payload = calls[0][1] + assert payload["text"].startswith("[UNKNOWN] Record deleted") + what_field = next( + field + for field in payload["blocks"][1]["fields"] + if field["text"].startswith("*What:*") + ) + assert f"Deleted asset {second_asset.name}" in what_field["text"] + + def test_delete_asset_404_not_found(second_asset): bad_id = 99999 response = client.delete(f"/asset/{bad_id}/remove") From 4d894ab75ada54cf846b37d3178e9ec37056ea81 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 11:18:55 -0600 Subject: [PATCH 109/160] Guard user dict access in crud_helper with isinstance check When AUTHENTIK_DISABLE_AUTHENTICATION=1 the auth dependency returns True instead of a claims dict. The previous `if user:` check passed for True, then user["sub"] raised TypeError, which bypassed FastAPI error handling and produced a raw 500 with no CORS headers. Changed to isinstance(user, dict) in model_adder and model_patcher so the audit fields are only written when a real claims dict is present. --- services/crud_helper.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/crud_helper.py b/services/crud_helper.py index 49d1c9146..2c7f3d41b 100644 --- a/services/crud_helper.py +++ b/services/crud_helper.py @@ -43,7 +43,7 @@ def model_adder(session, table, model, user=None, **kwargs): if kwargs: md.update(kwargs) - if user: + if isinstance(user, dict): # TODO: see note in "AuditMixin" md["created_by_id"] = user["sub"] md["created_by_name"] = user["name"] @@ -115,14 +115,14 @@ def model_patcher( else: setattr(item, key, value) - if user: + if isinstance(user, dict): item.updated_by_id = user["sub"] item.updated_by_name = user["name"] session.commit() session.refresh(item) - if user: + if isinstance(user, dict): resource_type = _resource_type_for_item(model, item) if resource_type: label = _resource_label(item) From bd791f9f87394ef40b1e3e5cf6b2cc3b3d4c7896 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 11:19:02 -0600 Subject: [PATCH 110/160] Guard user dict access in observation_helper with isinstance check Same isinstance(user, dict) fix applied to observation_model_patcher to prevent TypeError when the auth dependency yields True in local dev. --- services/observation_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/observation_helper.py b/services/observation_helper.py index f99241db0..4e1cab5e6 100644 --- a/services/observation_helper.py +++ b/services/observation_helper.py @@ -326,7 +326,7 @@ def observation_model_patcher( for key, value in payload.model_dump(exclude_unset=True).items(): setattr(observation, key, value) - if user: + if isinstance(user, dict): observation.updated_by_id = user["sub"] observation.updated_by_name = user["name"] From e167081a026c7b2d079f790cd039b03624729f0b Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 11:19:09 -0600 Subject: [PATCH 111/160] Add regression test for PATCH with boolean auth dependency Confirms the isinstance guard holds: when the auth dependency returns True (as it does locally with AUTHENTIK_DISABLE_AUTHENTICATION=1), PATCH /contact still returns 200 instead of a raw 500. --- tests/test_contact.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_contact.py b/tests/test_contact.py index 2076168ad..c4cb4392c 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -1117,3 +1117,29 @@ def test_delete_address_404_not_found(second_address): # assert response.status_code == 404 # data = response.json() # assert data["detail"] == f"ThingContactAssociation with ID {bad_id} not found." + + +# REGRESSION: isinstance(user, dict) guard in model_patcher ==================== +# When AUTHENTIK_DISABLE_AUTHENTICATION=1 the auth dependency returns True +# instead of a claims dict. Before the fix, model_patcher did user["sub"] on +# True, which raised TypeError and produced a 500 with no CORS headers. +# This test overrides the editor dependency with the boolean True (same as the +# live dev environment does) to confirm the patch still returns 200. + + +def test_patch_contact_with_bool_user(contact): + """PATCH returns 200 when the auth dependency yields True (not a dict).""" + app.dependency_overrides[amp_editor_function] = override_authentication( + default=True + ) + try: + payload = {"name": "Bool User Patch"} + response = client.patch(f"/contact/{contact.id}", json=payload) + assert response.status_code == 200 + assert response.json()["name"] == payload["name"] + finally: + # Restore the dict-user override so subsequent tests are unaffected. + app.dependency_overrides[amp_editor_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + cleanup_patch_test(Contact, payload, contact) From 5531e68e9037f77760a06ee4e09aba324fbbdc76 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Fri, 26 Jun 2026 11:31:01 -0600 Subject: [PATCH 112/160] Pass skip_if_exists=True when running seed from __main__ The Cypress CI runner calls python -m transfers.seed on each run. Without skip_if_exists, seed_all tries to insert contacts even when they already exist from a prior run, hitting the unique constraint on (name, organization) and crashing. The guard was already implemented in seed_all but not used at the call site. --- transfers/seed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/transfers/seed.py b/transfers/seed.py index f3cf741bb..bbe2c1885 100644 --- a/transfers/seed.py +++ b/transfers/seed.py @@ -460,4 +460,4 @@ def seed_all(n: int = 5, skip_if_exists: bool = False): if __name__ == "__main__": - seed_all(5) + seed_all(5, skip_if_exists=True) From 6db67bd0ee6ec258edbe0c52cc1563a3d7cd6c3e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:06:31 +0000 Subject: [PATCH 113/160] build(deps): bump the gha-minor-and-patch group with 2 updates (#744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the gha-minor-and-patch group with 2 updates: [actions/setup-python](https://github.com/actions/setup-python) and [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action). Updates `actions/setup-python` from 6.2.0 to 6.3.0
Release notes

Sourced from actions/setup-python's releases.

v6.3.0

What's Changed

Enhancement

Dependency update

Documentation

New Contributors

Full Changelog: https://github.com/actions/setup-python/compare/v6...v6.3.0

Commits

Updates `stefanzweifel/git-auto-commit-action` from 7.1.0 to 7.2.0
Release notes

Sourced from stefanzweifel/git-auto-commit-action's releases.

v7.2.0

Added

Fixed

Dependency Updates

Changelog

Sourced from stefanzweifel/git-auto-commit-action's changelog.

v7.2.0 - 2026-06-28

Added

Fixed

Dependency Updates

Commits
  • 4a55954 Update README.md
  • 9f6c933 Add hooks to run shell snippets around git operations (#411)
  • c365a74 Emit warning for pull_request_target trigger usage (#410)
  • d28176c Bump actions/checkout from 6 to 7 (#409)
  • 25df622 Add EXAMPLES.md
  • 32e9844 docs(action): fix input and output descriptions in action.yml (#406)
  • a3ed46f docs: fix typos, grammar, and formatting across markdown files (#408)
  • b4d688c docs: fix broken and redirecting URLs in README.md (#407)
  • f53a62c README: clearify meaning of the repository field (#404)
  • 4fc4bbf Bump release-drafter/release-drafter from 6 to 7 (#403)
  • Additional commits viewable in compare view

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/format_code.yml | 4 ++-- .github/workflows/jira_codex_pr.yml | 2 +- .github/workflows/tests.yml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/format_code.yml b/.github/workflows/format_code.yml index 209cec749..a9c98da93 100644 --- a/.github/workflows/format_code.yml +++ b/.github/workflows/format_code.yml @@ -19,7 +19,7 @@ jobs: - name: Check out source repository uses: actions/checkout@v7.0.0 - name: Set up Python environment - 3.12 - uses: actions/setup-python@v6.2.0 + uses: actions/setup-python@v6.3.0 with: python-version: "3.12" cache: "pip" @@ -42,7 +42,7 @@ jobs: options: "--verbose" - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@v7.1.0 + uses: stefanzweifel/git-auto-commit-action@v7.2.0 with: commit_message: Formatting changes branch: ${{ github.head_ref }} \ No newline at end of file diff --git a/.github/workflows/jira_codex_pr.yml b/.github/workflows/jira_codex_pr.yml index e94a51ba8..b6d130a7c 100644 --- a/.github/workflows/jira_codex_pr.yml +++ b/.github/workflows/jira_codex_pr.yml @@ -54,7 +54,7 @@ jobs: fi - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ env.PYTHON_VERSION }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 55ddff2ef..c49dc441e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -70,7 +70,7 @@ jobs: - name: Set up Python id: setup-python - uses: actions/setup-python@v6.2.0 + uses: actions/setup-python@v6.3.0 with: python-version-file: "pyproject.toml" @@ -162,7 +162,7 @@ jobs: - name: Set up Python id: setup-python - uses: actions/setup-python@v6.2.0 + uses: actions/setup-python@v6.3.0 with: python-version-file: "pyproject.toml" From 1f081a5f41c84fa829acaef74ac2000cda435bb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:19:33 +0000 Subject: [PATCH 114/160] build(deps): bump the uv-non-major group with 25 updates (#746) 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.4` | `1.18.5` | | [anyio](https://github.com/agronholm/anyio) | `4.14.0` | `4.14.1` | | [apitally[fastapi]](https://github.com/apitally/apitally-py) | `0.25.0` | `0.25.1` | | [click](https://github.com/pallets/click) | `8.4.1` | `8.4.2` | | [cloud-sql-python-connector](https://github.com/GoogleCloudPlatform/cloud-sql-python-connector) | `1.20.3` | `1.20.4` | | [fastapi](https://github.com/fastapi/fastapi) | `0.138.0` | `0.138.2` | | [google-auth](https://github.com/googleapis/google-cloud-python) | `2.55.0` | `2.55.1` | | [greenlet](https://github.com/python-greenlet/greenlet) | `3.5.2` | `3.5.3` | | [scramp](https://github.com/tlocke/scramp) | `1.4.9` | `1.4.10` | | [typer](https://github.com/fastapi/typer) | `0.26.7` | `0.26.8` | | [apitally](https://github.com/apitally/apitally-py) | `0.25.0` | `0.25.1` | | [babel](https://github.com/python-babel/babel) | `2.17.0` | `2.18.0` | | [dateparser](https://github.com/scrapinghub/dateparser) | `1.3.0` | `1.4.1` | | [ecdsa](https://github.com/tlsfuzzer/python-ecdsa) | `0.19.1` | `0.19.2` | | [filelock](https://github.com/tox-dev/py-filelock) | `3.18.0` | `3.29.4` | | [joserfc](https://github.com/authlib/joserfc) | `1.7.1` | `1.7.2` | | [markdown-it-py](https://github.com/executablebooks/markdown-it-py) | `4.0.0` | `4.2.0` | | [opentelemetry-api](https://github.com/open-telemetry/opentelemetry-python) | `1.39.1` | `1.43.0` | | [opentelemetry-sdk](https://github.com/open-telemetry/opentelemetry-python) | `1.39.1` | `1.43.0` | | [opentelemetry-semantic-conventions](https://github.com/open-telemetry/opentelemetry-python) | `0.60b1` | `0.64b0` | | [pygeofilter](https://github.com/geopython/pygeofilter) | `0.3.3` | `0.4.0` | | [pyyaml](https://github.com/yaml/pyyaml) | `6.0.2` | `6.0.3` | | [regex](https://github.com/mrabarnett/mrab-regex) | `2026.2.19` | `2026.6.28` | | [tzlocal](https://github.com/regebro/tzlocal) | `5.3.1` | `5.4.4` | | [werkzeug](https://github.com/pallets/werkzeug) | `3.1.6` | `3.1.8` | Updates `alembic` from 1.18.4 to 1.18.5
Release notes

Sourced from alembic's releases.

1.18.5

Released: June 25, 2026

usecase

  • [usecase] [commands] Added --splice support to the merge() command. Previously, the merge command would suggest using --splice when attempting to merge non-head revisions, but the flag was not actually accepted by the command. The splice parameter is now available in both the command-line interface and the command.merge() function, matching the existing support in command.revision(). Pull request courtesy Kadir Can Ozden.

    References: #1712

  • [usecase] [environment] Added ScriptDirectory.get_heads.consider_depends_on parameter to ScriptDirectory.get_heads(). When set to True, head revisions that are also a dependency of another revision via depends_on are excluded from the result, matching the effective heads that would be present in the alembic_version table after running all upgrades.

    References: #1806

bug

  • [bug] [autogenerate] Fixed rendering of dialect keyword arguments containing ~sqlalchemy.schema.Column objects within sequences, such as postgresql_include. These were previously rendered using repr(), producing invalid Python in the generated migration scripts. Column objects within list or tuple values are now correctly rendered as their string column names. Pull request courtesy Ajay Singh.

    References: #1258

  • [bug] [mysql] Implemented type comparison for ENUM datatypes on MySQL, which checks that the individual enum values are equivalent. If additional entries are on either side, this generates a diff. Changes of order do not generate a diff. Pull request courtesy Furkan Köykıran.

    References: #1745, #779

  • [bug] [operations] Fixed bug where the inline_references parameter of Operations.add_column() did not include foreign key referential actions such as ON DELETE, ON UPDATE, DEFERRABLE, INITIALLY, and MATCH when rendering the inline REFERENCES clause.

... (truncated)

Commits

Updates `anyio` from 4.14.0 to 4.14.1
Release notes

Sourced from anyio's releases.

4.14.1

  • Fixed teardown of higher-scoped async fixtures failing on asyncio with RuntimeError: Attempted to exit cancel scope in a different task than it was entered in when an async test raise an outcome exception (e.g., pytest.skip(), pytest.xfail(), or pytest.fail()) (#1179; PR by @​EmmanuelNiyonshuti)
  • Fixed CapacityLimiter.total_tokens rejecting a value of 0 when the limiter was instantiated outside of an event loop, contradicting the documented behavior of allowing 0 total tokens (#1183; PR by @​nyxst4ck)
Commits
  • 149b9e9 Bumped up the version
  • 377518c Bump actions/checkout from 6 to 7 in the github-actions group (#1186)
  • b42a2f5 [pre-commit.ci] pre-commit autoupdate (#1185)
  • 3ceb6ff Allow 0 tokens in a CapacityLimiter instantiated outside an event loop (#1183)
  • e10d1db Add missing await to open_file() in file I/O concurrency example (#1182)
  • 1dbc3b6 OutcomeException should not discard test runner_task (#1180)
  • See full diff in compare view

Updates `apitally[fastapi]` from 0.25.0 to 0.25.1
Release notes

Sourced from apitally[fastapi]'s releases.

v0.25.1

What's Changed

Fixes

Dependencies

Full Changelog: https://github.com/apitally/apitally-py/compare/v0.25.0...v0.25.1

Commits

Updates `click` from 8.4.1 to 8.4.2
Release notes

Sourced from click's releases.

8.4.2

This is the Click 8.4.1 fix release, which fixes bugs but does not otherwise change behavior and should not result in breaking changes compared to the latest feature release.

PyPI: https://pypi.org/project/click/8.4.2/ Changes: https://click.palletsprojects.com/page/changes/#version-8-4-2 Milestone: https://github.com/pallets/click/milestone/34

  • Fix Fish shell completion broken in 8.4.0 by #3126. Newlines and tabs in option help text are now escaped, keeping the original completion format while still supporting multi-line help. #3502 #3043 #3504 #3508
  • Deprecated commands and options with empty or missing help text no longer render a stray leading space before the (DEPRECATED) label. #3509
  • A {class}Group with invoke_without_command=True marks its subcommand as optional in the usage help, showing [COMMAND] instead of COMMAND. #3059 #3507
  • echo_via_pager flushes after each write, so passing a generator streams output to the pager incrementally instead of staying hidden until the pipe buffer fills. #3242 #2542 #3534
  • echo_via_pager and get_pager_file no longer close a borrowed stdout stream when no external pager runs, completing the partial I/O operation on closed file fix from #3482. #3449 #3533
  • Fix CLI usage symopsis for optional arguments producing double square brackets [[a|b|c]]... whose type already brackets their metavar. #3578
  • {func}version_option resolves a package_name that does not match an installed distribution as an import (top-level module) name via {func}importlib.metadata.packages_distributions. Packages whose top-level module name differs from their distribution name (PIL vs Pillow, jwt vs PyJWT) no longer raise RuntimeError out of the box. #2331 #1884 #3125 #3582
Changelog

Sourced from click's changelog.

Version 8.4.2

Unreleased

  • Fix Fish shell completion broken in 8.4.0 by {pr}3126. Newlines and tabs in option help text are now escaped, keeping the original completion format while still supporting multi-line help. {issue}3502 {issue}3043 {pr}3504 {pr}3508
  • Deprecated commands and options with empty or missing help text no longer render a stray leading space before the (DEPRECATED) label. {pr}3509
  • A {class}Group with invoke_without_command=True marks its subcommand as optional in the usage help, showing [COMMAND] instead of COMMAND. {issue}3059 {pr}3507
  • echo_via_pager flushes after each write, so passing a generator streams output to the pager incrementally instead of staying hidden until the pipe buffer fills. {issue}3242 {issue}2542 {pr}3534
  • echo_via_pager and get_pager_file no longer close a borrowed stdout stream when no external pager runs, completing the partial I/O operation on closed file fix from {pr}3482. {issue}3449 {pr}3533
Commits
  • b2e30a1 Release version 8.4.2
  • 7a16b20 Fix package_name resolution when module differs from distribution name (#3582)
  • bec5928 Fix package_name resolution when top-level module differs from distribution...
  • 916883a Fix tests to not rely on -Wdefault option (#3591)
  • 09195f6 Fix double-bracketing of choices in synopsis (#3578)
  • 1557e26 Check for warning exception with idiomatic context manager
  • d9ff133 Static typing improvements in click.shell_completion (#3460)
  • 762c97e Fix double-bracketing of choices in synopsis
  • 8929d39 Convert changes to markdown. (#3559)
  • 237be50 Move changes headings down a level.
  • Additional commits viewable in compare view

Updates `cloud-sql-python-connector` from 1.20.3 to 1.20.4
Release notes

Sourced from cloud-sql-python-connector's releases.

v1.20.4

1.20.4 (2026-06-26)

Bug Fixes

Changelog

Sourced from cloud-sql-python-connector's changelog.

1.20.4 (2026-06-26)

Bug Fixes

Commits

Updates `fastapi` from 0.138.0 to 0.138.2
Release notes

Sourced from fastapi's releases.

0.138.1

Refactors

  • ♻️ Refactor Library Skills, make info easier to find for agents. PR #15841 by @​tiangolo.

Internal

Commits
  • 702fea8 🔖 Release version 0.138.2 (#15864)
  • 6466865 📝 Update release notes
  • b790e14 ♻️ Make app.frontend() return 404 for methods other than GET or HEAD wi...
  • c2708d9 📝 Update release notes
  • 403b1fa 🔧 Update sponsors: remove Stainless (#15862)
  • 1929ac2 📝 Update release notes
  • cba4158 ♻️ Refactor how sponsors data is handled for banners (#15852)
  • b90c49a 🔖 Release version 0.138.1 (#15842)
  • 1f2f3df 📝 Update release notes
  • 0af003a ♻️ Refactor Library Skills, make info easier to find for agents (#15841)
  • Additional commits viewable in compare view

Updates `google-auth` from 2.55.0 to 2.55.1
Release notes

Sourced from google-auth's releases.

google-auth: v2.55.1

2.55.1 (2026-06-25)

Bug Fixes

  • auth: lower regional access boundary logs from warning to debug. (#17571) (1ef4183), closes #17515
Commits
  • 900d51f chore: release main (#17543)
  • 1ef4183 fix(auth): lower regional access boundary logs from warning to debug. (#17571)
  • bd782cf feat: regenerate google-cloud-bigtable (#17575)
  • 57ebaa3 feat: regenerate google-cloud-firestore (#17577)
  • 140d86f feat: regenerate google-cloud-compute (#17576)
  • 39b252f feat: regenerate google-cloud-spanner (#17578)
  • 421eebd feat: support interactive execution of deferred DataFrames in TableWidget (#1...
  • 3619b29 feat: support gemini-3.x models in loader and update default model to gemini-...
  • 17bef41 test(bigquery-magics): make table_id parsing check version-agnostic (#17562)
  • e688531 bigtable: add ValueBitmaskFilter for data client (#17567)
  • Additional commits viewable in compare view

Updates `greenlet` from 3.5.2 to 3.5.3
Changelog

Sourced from greenlet's changelog.

3.5.3 (2026-06-26)

  • Fix a crash on free-threaded builds when multiple greenlets were holding a critical section on an object and the GIL for the thread was dropped. See issue 513 <https://github.com/python-greenlet/greenlet/issues/513>_. Thanks to ddorian.
Commits
  • 6ee8c2c Preparing release 3.5.3
  • 6ec0bbb Merge pull request #514 from python-greenlet/issue513-preserve-crit-section
  • c03a7e6 Py3.13+: Preserve thread state critical_section to prevent crash on free-thre...
  • bc10829 Speed up manylinux test runs by only running the core checks; only start many...
  • c2db75d Back to development: 3.5.3
  • See full diff in compare view

Updates `scramp` from 1.4.9 to 1.4.10
Commits

Updates `typer` from 0.26.7 to 0.26.8
Release notes

Sourced from typer's releases.

0.26.8

Fixes

  • 🐛 Make second column of Rich help output reflect the type consistently, even when using metavar. PR #1410 by @​svlandeg.
  • 🐛 Fix formatting in NoSuchOption.format_message(). PR #1843 by @​foomunleong.

Docs

  • 📝 Update docs badges: remove Publish badge, it doesn't give extra information. PR #1850 by @​tiangolo.
  • 📝 Fix formatting for help link to support GitHub-specific overview edge-case. PR #1826 by @​phalberg.

Internal

Changelog

Sourced from typer's changelog.

0.26.8 (2026-06-25)

Fixes

  • 🐛 Make second column of Rich help output reflect the type consistently, even when using metavar. PR #1410 by @​svlandeg.
  • 🐛 Fix formatting in NoSuchOption.format_message(). PR #1843 by @​foomunleong.

Docs

  • 📝 Update docs badges: remove Publish badge, it doesn't give extra information. PR #1850 by @​tiangolo.
  • 📝 Fix formatting for help link to support GitHub-specific overview edge-case. PR #1826 by @​phalberg.

Internal

Commits
  • b210c0e 🔖 Release version 0.26.8 (#1859)
  • 51ae100 📝 Update release notes
  • 0c15b1b 🐛 Make second column of Rich help output reflect the type consistently, even ...
  • b7cb8c7 📝 Update release notes
  • 5285cd4 👷 Simplify pull request workflow triggers (#1858)
  • b27385b 📝 Update release notes
  • e64958f 👷 Update issue-manager to 0.7.1 (#1857)
  • 1b02fb3 📝 Update release notes
  • e64632c ⬆️ Update issue-manager to 0.7.0 (#1856)
  • 289b6a6 📝 Update release notes
  • Additional commits viewable in compare view

Updates `apitally` from 0.25.0 to 0.25.1
Release notes

Sourced from apitally's releases.

v0.25.1

What's Changed

Fixes

Dependencies

Full Changelog: https://github.com/apitally/apitally-py/compare/v0.25.0...v0.25.1

Commits

Updates `babel` from 2.17.0 to 2.18.0
Release notes

Sourced from babel's releases.

v2.18.0

Happy 2026! Like last year's release (ahem...), this one too is being made from FOSDEM 2026, in Brussels, Belgium. 🇧🇪 We'll aspire for a less glacial release cycle for 2.19. 😁

Please see CHANGELOG.rst for the detailed change log.

Full Changelog: https://github.com/python-babel/babel/compare/v2.17.0...v2.18.0

Changelog

Sourced from babel's changelog.

Version 2.18.0

Happy 2026! This release is, coincidentally, also being made from FOSDEM.

We will aspire for a slightly less glacial release cadence in this year; there are interesting features in the pipeline.

Features


* Core: Add `babel.core.get_cldr_version()` by @akx in :gh:`1242`
* Core: Use CLDR 47 by @tomasr8 in :gh:`1210`
* Core: Use canonical IANA zone names in zone_territories by @akx in
:gh:`1220`
* Messages: Improve extract performance via ignoring directories early
during os.walk by @akx in :gh:`968`
* Messages: Merge in per-format keywords and auto_comments by @akx in
:gh:`1243`
* Messages: Update keywords for extraction of dpgettext and dnpgettext
by @mardiros in :gh:`1235`
* Messages: Validate all plurals in Python format checker by @tomasr8 in
:gh:`1188`
* Time: Use standard library `timezone` instead of `FixedOffsetTimezone`
by @akx in :gh:`1203`

Bugfixes

  • Core: Fix formatting for "Empty locale identifier" exception added in #1164 by @​akx in :gh:1184
  • Core: Improve handling of no-inheritance-marker in timezone data by @​akx in :gh:1194
  • Core: Make the number pattern regular expression more efficient by @​akx in :gh:1213
  • Messages: Keep translator comments next to the translation function call by @​akx in :gh:1196
  • Numbers: Fix KeyError that occurred when formatting compact currencies of exactly one thousand in several locales by @​bartbroere in :gh:1246

Other improvements


* Core: Avoid unnecessary uses of `map()` by @akx in :gh:`1180`
* Messages: Have init-catalog create directories too by @akx in
:gh:`1244`
* Messages: Optimizations for read_po by @akx in :gh:`1200`
* Messages: Use pathlib.Path() in catalog frontend; improve test
coverage by @akx in :gh:`1204`

Infrastructure and documentation

  • CI: Renovate CI & lint tools by @​akx in :gh:1228
  • CI: Tighten up CI with Zizmor by @​akx in :gh:1230
  • CI: make job permissions explicit by @​akx in :gh:1227
  • Docs: Add SECURITY.md by @​akx in :gh:1229
  • Docs: Remove u string prefix from docs by @​verhovsky in :gh:1174
  • Docs: Update dates.rst with current unicode.org tr35 link by @​clach04 in :gh:1189
  • General: Add some PyPI classifiers by @​tomasr8 in :gh:1186
  • General: Apply reformatting by hand and with Ruff by @​akx in :gh:1202
  • General: Test on and declare support for Python 3.14 by @​akx in :gh:1233

... (truncated)

Commits
  • 56c63ca Prepare for 2.18.0 (#1248)
  • 73015a1 Add user-agent to CLDR downloader (#1247)
  • 29bd362 Fix formatting compact currencies of exactly one thousand in several locales ...
  • 851db43 Reuse InitCatalog's guts in UpdateCatalog (#1244)
  • fd00e60 Extract: Merge in per-format keywords and auto_comments (#1243)
  • 12a14b6 Add dpgettext and dnpgettext support (#1235)
  • 7110e62 Use canonical IANA zone names in zone_territories (#1220)
  • e91c346 Improve extract performance via ignoring directories early during os.walk (#968)
  • 0c4f378 Convert Unittest testcases with setup/teardown to fixtures (#1240)
  • 218c96e Add babel.core.get_cldr_version() (#1242)
  • Additional commits viewable in compare view

Updates `dateparser` from 1.3.0 to 1.4.1
Release notes

Sourced from dateparser's releases.

1.4.1

Breaking changes:

  • Remove fastText language detection support: the fasttext extra is dropped and detect_languages() now raises ImportError. Migrate to the langdetect extra, which also unblocks numpy 2.x compatibility (#1315)

Security fixes:

  • Make digit quantifiers possessive in the relative-date regexes to prevent quadratic backtracking (ReDoS) on long digit runs (#1335)

New features:

  • Add the USE_GIVEN_LANGUAGE_ORDER setting to try languages and locales in the order given rather than by frequency (#789)

Fixes:

  • Preserve explicit signs on individual components when parsing relative dates that combine decades with years, such as "-1 decade +2 years" (#1330)
  • Fall back to other provided languages in search_dates when the detected language yields no dates (#1331)
  • Parse relative date expressions with spaces between the sign and number, such as "now - 2 hours" and "now + 1 day" (#1327)
  • Use the parser-relative now for the current month when filling in incomplete dates so the month and day stay consistent (#1332)
  • Fix Norwegian Bokmål (nb) parsing of relative date expressions such as "3 måneder siden" and "om 2 måneder" (#1334)
  • Parse abbreviated English month expressions such as "1mon ago" and "3mons ago" (#1329)
  • Preserve surrounding whitespace when removing skip tokens during translation to avoid spurious double spaces (#1324)

Improvements:

  • Move project metadata and build configuration to pyproject.toml (#1311)
  • Add alternative Korean date expressions for today, yesterday, tomorrow, and "N months ago/later" (#1289)
  • Expand Czech date translations with additional inflections, word numbers, decade and century expressions, and clock phrases like "čtvrt na tři" (#1325)
  • Replace internal OrderedDict usage with the built-in dict (#1328)

1.4.0

Security fixes:

  • Remove import-time loading of timezone offset data from pickle to prevent unsafe deserialization from packaged data

... (truncated)

Changelog

Sourced from dateparser's changelog.

1.4.1 (2026-06-15)

Breaking changes:

  • Remove fastText language detection support: the fasttext extra is dropped and detect_languages() now raises ImportError. Migrate to the langdetect extra, which also unblocks numpy 2.x compatibility (#1315)

Security fixes:

  • Make digit quantifiers possessive in the relative-date regexes to prevent quadratic backtracking (ReDoS) on long digit runs (#1335)

New features:

  • Add the USE_GIVEN_LANGUAGE_ORDER setting to try languages and locales in the order given rather than by frequency (#789)

Fixes:

  • Preserve explicit signs on individual components when parsing relative dates that combine decades with years, such as "-1 decade +2 years" (#1330)
  • Fall back to other provided languages in search_dates when the detected language yields no dates (#1331)
  • Parse relative date expressions with spaces between the sign and number, such as "now - 2 hours" and "now + 1 day" (#1327)
  • Use the parser-relative now for the current month when filling in incomplete dates so the month and day stay consistent (#1332)
  • Fix Norwegian Bokmål (nb) parsing of relative date expressions such as "3 måneder siden" and "om 2 måneder" (#1334)
  • Parse abbreviated English month expressions such as "1mon ago" and "3mons ago" (#1329)
  • Preserve surrounding whitespace when removing skip tokens during translation to avoid spurious double spaces (#1324)

Improvements:

  • Move project metadata and build configuration to pyproject.toml (#1311)
  • Add alternative Korean date expressions for today, yesterday, tomorrow, and "N months ago/later" (#1289)
  • Expand Czech date translations with additional inflections, word numbers, decade and century expressions, and clock phrases like "čtvrt na tři" (#1325)
  • Replace internal OrderedDict usage with the built-in dict (#1328)

1.4.0 (2026-03-26)

... (truncated)

Commits
  • 08c78d3 1.4.1 release (#1337)
  • a049fd1 Add Korean alternative date expressions (#1289)
  • 98b9c32 make relative-date regex digit quantifiers possessive (#1335)
  • 5c6d97f fix: Preserve explicit signs for years when combined with decades (#1330)
  • 0d11d82 Locale order support (#789)
  • 081d251 fix(nb): improve Norwegian Bokmål date parsing (#1334)
  • 333e519 Fix merge issue (#1333)
  • 5c437a1 Move project metadata and configuration to 'pyproject.toml' (#1311)
  • b107ac3 Replace internal OrderedDict usage with dict (#1328) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 20 +- requirements.txt | 523 ++++++++++++++++++++++++++++++----------------- uv.lock | 180 ++++++++-------- 3 files changed, 432 insertions(+), 291 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2253d7ee5..11ff4d3f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,10 +10,10 @@ dependencies = [ "aiohttp==3.14.1", "aiosignal==1.4.0", "aiosqlite==0.22.1", - "alembic==1.18.4", + "alembic==1.18.5", "annotated-types==0.7.0", - "anyio==4.14.0", - "apitally[fastapi]==0.25.0", + "anyio==4.14.1", + "apitally[fastapi]==0.25.1", "asgiref==3.11.1", "asn1crypto==1.5.1", "asyncpg==0.31.0", @@ -24,24 +24,24 @@ dependencies = [ "certifi==2026.6.17", "cffi==2.0.0", "charset-normalizer==3.4.7", - "click==8.4.1", - "cloud-sql-python-connector==1.20.3", + "click==8.4.2", + "cloud-sql-python-connector==1.20.4", "cryptography==48.0.1", "dnspython==2.8.0", "dotenv==0.9.9", "email-validator==2.3.0", - "fastapi==0.138.0", + "fastapi==0.138.2", "fastapi-pagination==0.15.15", "frozenlist==1.8.0", "geoalchemy2==0.20.0", "google-api-core==2.31.0", - "google-auth==2.55.0", + "google-auth==2.55.1", "google-cloud-core==2.6.0", "google-cloud-storage==3.12.0", "google-crc32c==1.8.0", "google-resumable-media==2.10.0", "googleapis-common-protos==1.75.0", - "greenlet==3.5.2", + "greenlet==3.5.3", "gunicorn==23.0.0", "h11==0.16.0", "httpcore==1.0.9", @@ -81,7 +81,7 @@ dependencies = [ "pytz==2026.2", "requests==2.34.2", "rsa==4.9.1", - "scramp==1.4.9", + "scramp==1.4.10", "sentry-sdk[fastapi]==2.63.0", "shapely==2.1.2", "six==1.17.0", @@ -93,7 +93,7 @@ dependencies = [ "sqlparse>=0.5.5", "starlette==1.3.1", "starlette-admin[i18n]==0.16.1", - "typer==0.26.7", + "typer==0.26.8", "typing-extensions==4.15.0", "typing-inspection==0.4.2", "tzdata==2025.3", diff --git a/requirements.txt b/requirements.txt index 36342504b..a826da97f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -95,9 +95,9 @@ aiosqlite==0.22.1 \ --hash=sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650 \ --hash=sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb # via ocotilloapi -alembic==1.18.4 \ - --hash=sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a \ - --hash=sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc +alembic==1.18.5 \ + --hash=sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc \ + --hash=sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e # via ocotilloapi annotated-doc==0.0.4 \ --hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \ @@ -111,16 +111,16 @@ annotated-types==0.7.0 \ # via # ocotilloapi # pydantic -anyio==4.14.0 \ - --hash=sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89 \ - --hash=sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9 +anyio==4.14.1 \ + --hash=sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72 \ + --hash=sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e # via # httpx # ocotilloapi # starlette -apitally==0.25.0 \ - --hash=sha256:395ff0bfc04a238c6b0c843ab0fc957ec5cb634da2e260b1049c14b5beb78020 \ - --hash=sha256:8f6a5c015aac9c69d0b3a393ece62378f01a28e47b987a40d8001c36be74adb6 +apitally==0.25.1 \ + --hash=sha256:2681e925deffbc94eb7fc65e1f0db397df58634ab1d90597be458d69b2185f7b \ + --hash=sha256:8281fa67fb5cae8cd5d84146cd5e2e0851be7b8c5fe27a6605cdaa5065d00483 # via ocotilloapi asgiref==3.11.1 \ --hash=sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce \ @@ -172,9 +172,9 @@ authlib==1.7.2 \ --hash=sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231 \ --hash=sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f # via ocotilloapi -babel==2.17.0 \ - --hash=sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d \ - --hash=sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2 +babel==2.18.0 \ + --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ + --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 # via # pygeoapi # starlette-admin @@ -339,9 +339,9 @@ charset-normalizer==3.4.7 \ # via # ocotilloapi # requests -click==8.4.1 \ - --hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 \ - --hash=sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96 +click==8.4.2 \ + --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 # via # cligj # flask @@ -354,9 +354,9 @@ cligj==0.7.2 \ --hash=sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27 \ --hash=sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df # via rasterio -cloud-sql-python-connector==1.20.3 \ - --hash=sha256:4b6f5c376982206fb0e62545c86d23ee49d045f2e71da817a326654cd169149a \ - --hash=sha256:b4732920b5632be946921fa649a6ddeabfed9447a2b8088d55a6b08919ae3b85 +cloud-sql-python-connector==1.20.4 \ + --hash=sha256:4c1cd8b573d5e9b93a6f390ccf772fa431afdcc32025b1577e2bafa89756a9f6 \ + --hash=sha256:fe2dbee747543ad2c720760c53064f0ef42ed04218981e1f7231362a88b3cf44 # via ocotilloapi colorama==0.4.6 ; sys_platform == 'win32' \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ @@ -414,9 +414,9 @@ cryptography==48.0.1 \ # google-auth # joserfc # ocotilloapi -dateparser==1.3.0 \ - --hash=sha256:5bccf5d1ec6785e5be71cc7ec80f014575a09b4923e762f850e57443bddbf1a5 \ - --hash=sha256:8dc678b0a526e103379f02ae44337d424bd366aac727d3c6cf52ce1b01efbb5a +dateparser==1.4.1 \ + --hash=sha256:f25d4e051a84be27a35bd297e3e1dc59ff78373701b89be352ba80372d22d0d0 \ + --hash=sha256:f265df13c0380e2e07543ba74b67c0681aaa1096981ffcd35227e1aa0cb81c7c # via pygeofilter dnspython==2.8.0 \ --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \ @@ -428,17 +428,17 @@ dnspython==2.8.0 \ dotenv==0.9.9 \ --hash=sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9 # via ocotilloapi -ecdsa==0.19.1 \ - --hash=sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3 \ - --hash=sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61 +ecdsa==0.19.2 \ + --hash=sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930 \ + --hash=sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399 # via python-jose email-validator==2.3.0 \ --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 # via ocotilloapi -fastapi==0.138.0 \ - --hash=sha256:b6f54fd1bd72c80b0f899f172c61a600f6f7af9b43d4d772a018f35624048cb0 \ - --hash=sha256:d445a4877636ad191e7053e08c9bf98cb921a6756776848400bb773d1740c061 +fastapi==0.138.2 \ + --hash=sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8 \ + --hash=sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615 # via # apitally # fastapi-pagination @@ -448,9 +448,9 @@ fastapi-pagination==0.15.15 \ --hash=sha256:d6e9e4bc4d6e20709dcabc11b16056cd5cd184c995ee214b0190f6b81426fa0c \ --hash=sha256:dc828d7cd15614c650c284bd2c3a98a8a2d9ce340508be3970dc8986908a02aa # via ocotilloapi -filelock==3.18.0 \ - --hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \ - --hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de +filelock==3.29.4 \ + --hash=sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a \ + --hash=sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767 # via pygeoapi flask==3.1.3 \ --hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \ @@ -538,9 +538,9 @@ google-api-core==2.31.0 \ # google-cloud-core # google-cloud-storage # ocotilloapi -google-auth==2.55.0 \ - --hash=sha256:a17cef9dedf98c4ebae2fb0c48c8f75952c877cbc2efe09f329ef16c2783d88a \ - --hash=sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb +google-auth==2.55.1 \ + --hash=sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995 \ + --hash=sha256:fb2d9b730f2c9b8d326ec8d7222f21aef2ead15bf0513793d6442485d87af0a1 # via # cloud-sql-python-connector # google-api-core @@ -585,57 +585,86 @@ googleapis-common-protos==1.75.0 \ # via # google-api-core # ocotilloapi -greenlet==3.5.2 \ - --hash=sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34 \ - --hash=sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473 \ - --hash=sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32 \ - --hash=sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4 \ - --hash=sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb \ - --hash=sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8 \ - --hash=sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682 \ - --hash=sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163 \ - --hash=sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c \ - --hash=sha256:2debcd0ef9455b7d4879589903efc8e497d4b8fb8c0ae772309e44d1ca5e957f \ - --hash=sha256:2ee6288f1933d698b4f098127ed17bda2910a75d2807915bd16294a972055d6c \ - --hash=sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32 \ - --hash=sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de \ - --hash=sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904 \ - --hash=sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e \ - --hash=sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a \ - --hash=sha256:423167363c510a75b649f5cd58d873c29498ea03598b9e4b1c3b73e0f899f3d5 \ - --hash=sha256:537c5c4f30395020bb9f48f53146070e3b997c3c75da14011ab732aaa19ce3ef \ - --hash=sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8 \ - --hash=sha256:5930d3946ecae99fa7fc0e3f3ae515426ad85058ebd9bfc6c00cca8016e6206b \ - --hash=sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9 \ - --hash=sha256:6e9e49d732ee92a189bb7035e293029244aeba648297a9b856dc733d17ca7f0d \ - --hash=sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f \ - --hash=sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39 \ - --hash=sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9 \ - --hash=sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce \ - --hash=sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00 \ - --hash=sha256:9558cae989faeab6fbb425cd98a0cfa4190a47fba6443973fbee0a1eb0b0b6c3 \ - --hash=sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c \ - --hash=sha256:9dc23f0e5ad76415457212a4b947d22ebe4dc80baf02adf7dd5647a90f38bb4e \ - --hash=sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb \ - --hash=sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5 \ - --hash=sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3 \ - --hash=sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317 \ - --hash=sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a \ - --hash=sha256:a9476cbead736dc48ce89e3cd97acff95ecc48cbf21273603a438f9870c4a014 \ - --hash=sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0 \ - --hash=sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1 \ - --hash=sha256:b4cad42662c796334c2d24607c411e3ed82481c1fb4e1e8ec3a5a8416060092e \ - --hash=sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5 \ - --hash=sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a \ - --hash=sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c \ - --hash=sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73 \ - --hash=sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f \ - --hash=sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95 \ - --hash=sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c \ - --hash=sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7 \ - --hash=sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4 \ - --hash=sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74 \ - --hash=sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c +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 # via # ocotilloapi # sqlalchemy @@ -694,9 +723,9 @@ jinja2==3.1.6 \ # ocotilloapi # pygeoapi # starlette-admin -joserfc==1.7.1 \ - --hash=sha256:77d0b76514879c68c6f433bc5b7357a4ab72008ff1e33d8379fd11d72bd8ca81 \ - --hash=sha256:b3e3d655612e2e1ef67b2600f2f420e12e537b020208fab1761fad647319c164 +joserfc==1.7.2 \ + --hash=sha256:537ffb8888b2df039cb5b6d017d7cff6f09d521ce65d89cc9b8ab752b1cff947 \ + --hash=sha256:ddd818c0ca9b4f17bbc2d72cb3966e6ded7502be089316c62c3cc64ae86132b5 # via authlib jsonschema==4.26.0 \ --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ @@ -716,9 +745,9 @@ mako==1.3.12 \ # via # alembic # ocotilloapi -markdown-it-py==4.0.0 \ - --hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 \ - --hash=sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3 +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a # via rich markupsafe==3.0.3 \ --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ @@ -895,19 +924,19 @@ numpy==2.5.0 \ # pandas-stubs # rasterio # shapely -opentelemetry-api==1.39.1 \ - --hash=sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950 \ - --hash=sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c +opentelemetry-api==1.43.0 \ + --hash=sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1 \ + --hash=sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd # via # opentelemetry-sdk # opentelemetry-semantic-conventions -opentelemetry-sdk==1.39.1 \ - --hash=sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c \ - --hash=sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6 +opentelemetry-sdk==1.43.0 \ + --hash=sha256:d1323a547c1ce69d6a069a17a44b7da82bb8b332051ecb074041f87642c86823 \ + --hash=sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9 # via apitally -opentelemetry-semantic-conventions==0.60b1 \ - --hash=sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953 \ - --hash=sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb +opentelemetry-semantic-conventions==0.64b0 \ + --hash=sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c \ + --hash=sha256:ea77e85e354b8f604ddbe5f3d9135216f982fa4d77e5859ac30f6d8a50505aa6 # via opentelemetry-sdk packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ @@ -1223,9 +1252,9 @@ pygeoapi==0.23.4 \ --hash=sha256:7f0fd854575a0da049b64907b56fc0f77ab97768414c1397897e60a0e563438d \ --hash=sha256:935a22761eb0d8736f7b0f2c8384672f5341577509803e35f33f6e78299221ae # via ocotilloapi -pygeofilter==0.3.3 \ - --hash=sha256:8b9fec05ba144943a1e415b6ac3752ad6011f44aad7d1bb27e7ef48b073460bd \ - --hash=sha256:e719fcb929c6b60bca99de0cfde5f95bc3245cab50516c103dae1d4f12c4c7b6 +pygeofilter==0.4.0 \ + --hash=sha256:cbb4a5f14af0b87e4f0c0c81c659ff64e44351c98e9f61d36af515d896fa8a05 \ + --hash=sha256:ddb74c8233f4fd1b62b80a0ecf4e4f9aff178b8c61334754288d1622c8db71ec # via pygeoapi pygeoif==1.6.0 \ --hash=sha256:02f84807dadbaf1941c4bb2a9ef1ebac99b1b0404597d2602efdbb58910c69c9 \ @@ -1340,17 +1369,80 @@ pytz==2026.2 \ # ocotilloapi # pandas # pygeoapi -pyyaml==6.0.2 \ - --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \ - --hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \ - --hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \ - --hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \ - --hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \ - --hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \ - --hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \ - --hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \ - --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \ - --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 # via pygeoapi rasterio==1.5.0 \ --hash=sha256:015c1ab6e5453312c5e29692752e7ad73568fe4d13567cbd448d7893128cbd2d \ @@ -1385,72 +1477,121 @@ referencing==0.37.0 \ # via # jsonschema # jsonschema-specifications -regex==2026.2.19 \ - --hash=sha256:015088b8558502f1f0bccd58754835aa154a7a5b0bd9d4c9b7b96ff4ae9ba876 \ - --hash=sha256:02b9e1b8a7ebe2807cd7bbdf662510c8e43053a23262b9f46ad4fc2dfc9d204e \ - --hash=sha256:03d191a9bcf94d31af56d2575210cb0d0c6a054dbcad2ea9e00aa4c42903b919 \ - --hash=sha256:0d0e72703c60d68b18b27cde7cdb65ed2570ae29fb37231aa3076bfb6b1d1c13 \ - --hash=sha256:11c138febb40546ff9e026dbbc41dc9fb8b29e61013fa5848ccfe045f5b23b83 \ - --hash=sha256:127ea69273485348a126ebbf3d6052604d3c7da284f797bba781f364c0947d47 \ - --hash=sha256:17648e1a88e72d88641b12635e70e6c71c5136ba14edba29bf8fc6834005a265 \ - --hash=sha256:1e7a08622f7d51d7a068f7e4052a38739c412a3e74f55817073d2e2418149619 \ - --hash=sha256:2905ff4a97fad42f2d0834d8b1ea3c2f856ec209837e458d71a061a7d05f9f01 \ - --hash=sha256:294c0fb2e87c6bcc5f577c8f609210f5700b993151913352ed6c6af42f30f95f \ - --hash=sha256:2c1693ca6f444d554aa246b592355b5cec030ace5a2729eae1b04ab6e853e768 \ - --hash=sha256:2f914ae8c804c8a8a562fe216100bc156bfb51338c1f8d55fe32cf407774359a \ - --hash=sha256:2fedd459c791da24914ecc474feecd94cf7845efb262ac3134fe27cbd7eda799 \ - --hash=sha256:311fcccb76af31be4c588d5a17f8f1a059ae8f4b097192896ebffc95612f223a \ - --hash=sha256:3aa0944f1dc6e92f91f3b306ba7f851e1009398c84bfd370633182ee4fc26a64 \ - --hash=sha256:4071209fd4376ab5ceec72ad3507e9d3517c59e38a889079b98916477a871868 \ - --hash=sha256:43cdde87006271be6963896ed816733b10967baaf0e271d529c82e93da66675b \ - --hash=sha256:46e69a4bf552e30e74a8aa73f473c87efcb7f6e8c8ece60d9fd7bf13d5c86f02 \ - --hash=sha256:4a02faea614e7fdd6ba8b3bec6c8e79529d356b100381cec76e638f45d12ca04 \ - --hash=sha256:50f1ee9488dd7a9fda850ec7c68cad7a32fa49fd19733f5403a3f92b451dcf73 \ - --hash=sha256:516ee067c6c721d0d0bfb80a2004edbd060fffd07e456d4e1669e38fe82f922e \ - --hash=sha256:5390b130cce14a7d1db226a3896273b7b35be10af35e69f1cca843b6e5d2bb2d \ - --hash=sha256:5a8f28dd32a4ce9c41758d43b5b9115c1c497b4b1f50c457602c1d571fa98ce1 \ - --hash=sha256:5e3a31e94d10e52a896adaa3adf3621bd526ad2b45b8c2d23d1bbe74c7423007 \ - --hash=sha256:5e56c669535ac59cbf96ca1ece0ef26cb66809990cda4fa45e1e32c3b146599e \ - --hash=sha256:5ec1d7c080832fdd4e150c6f5621fe674c70c63b3ae5a4454cebd7796263b175 \ - --hash=sha256:6380f29ff212ec922b6efb56100c089251940e0526a0d05aa7c2d9b571ddf2fe \ - --hash=sha256:64128549b600987e0f335c2365879895f860a9161f283b14207c800a6ed623d3 \ - --hash=sha256:654dc41a5ba9b8cc8432b3f1aa8906d8b45f3e9502442a07c2f27f6c63f85db5 \ - --hash=sha256:655f553a1fa3ab8a7fd570eca793408b8d26a80bfd89ed24d116baaf13a38969 \ - --hash=sha256:6c8fb3b19652e425ff24169dad3ee07f99afa7996caa9dfbb3a9106cd726f49a \ - --hash=sha256:6fb8cb09b10e38f3ae17cc6dc04a1df77762bd0351b6ba9041438e7cc85ec310 \ - --hash=sha256:7187fdee1be0896c1499a991e9bf7c78e4b56b7863e7405d7bb687888ac10c4b \ - --hash=sha256:74ff212aa61532246bb3036b3dfea62233414b0154b8bc3676975da78383cac3 \ - --hash=sha256:77cfd6b5e7c4e8bf7a39d243ea05882acf5e3c7002b0ef4756de6606893b0ecd \ - --hash=sha256:790dbf87b0361606cb0d79b393c3e8f4436a14ee56568a7463014565d97da02a \ - --hash=sha256:80caaa1ddcc942ec7be18427354f9d58a79cee82dea2a6b3d4fd83302e1240d7 \ - --hash=sha256:8457c1bc10ee9b29cdfd897ccda41dce6bde0e9abd514bcfef7bcd05e254d411 \ - --hash=sha256:8497421099b981f67c99eba4154cf0dfd8e47159431427a11cfb6487f7791d9e \ - --hash=sha256:8abe671cf0f15c26b1ad389bf4043b068ce7d3b1c5d9313e12895f57d6738555 \ - --hash=sha256:8df08decd339e8b3f6a2eb5c05c687fe9d963ae91f352bc57beb05f5b2ac6879 \ - --hash=sha256:8e6e77cd92216eb489e21e5652a11b186afe9bdefca8a2db739fd6b205a9e0a4 \ - --hash=sha256:8edda06079bd770f7f0cf7f3bba1a0b447b96b4a543c91fe0c142d034c166161 \ - --hash=sha256:93d881cab5afdc41a005dba1524a40947d6f7a525057aa64aaf16065cf62faa9 \ - --hash=sha256:997862c619994c4a356cb7c3592502cbd50c2ab98da5f61c5c871f10f22de7e5 \ - --hash=sha256:9cbc69eae834afbf634f7c902fc72ff3e993f1c699156dd1af1adab5d06b7fe7 \ - --hash=sha256:9e6693b8567a59459b5dda19104c4a4dbbd4a1c78833eacc758796f2cfef1854 \ - --hash=sha256:9fff45852160960f29e184ec8a5be5ab4063cfd0b168d439d1fc4ac3744bf29e \ - --hash=sha256:a09ae430e94c049dc6957f6baa35ee3418a3a77f3c12b6e02883bd80a2b679b0 \ - --hash=sha256:a178df8ec03011153fbcd2c70cb961bc98cbbd9694b28f706c318bee8927c3db \ - --hash=sha256:ab780092b1424d13200aa5a62996e95f65ee3db8509be366437439cdc0af1a9f \ - --hash=sha256:b5100acb20648d9efd3f4e7e91f51187f95f22a741dcd719548a6cf4e1b34b3f \ - --hash=sha256:b9ab8dec42afefa6314ea9b31b188259ffdd93f433d77cad454cd0b8d235ce1c \ - --hash=sha256:bcf57d30659996ee5c7937999874504c11b5a068edc9515e6a59221cc2744dd1 \ - --hash=sha256:c0761d7ae8d65773e01515ebb0b304df1bf37a0a79546caad9cbe79a42c12af7 \ - --hash=sha256:c0924c64b082d4512b923ac016d6e1dcf647a3560b8a4c7e55cbbd13656cb4ed \ - --hash=sha256:c13228fbecb03eadbfd8f521732c5fda09ef761af02e920a3148e18ad0e09968 \ - --hash=sha256:c227f2922153ee42bbeb355fd6d009f8c81d9d7bdd666e2276ce41f53ed9a743 \ - --hash=sha256:c7e121a918bbee3f12ac300ce0a0d2f2c979cf208fb071ed8df5a6323281915c \ - --hash=sha256:cce8027010d1ffa3eb89a0b19621cdc78ae548ea2b49fea1f7bfb3ea77064c2b \ - --hash=sha256:d00c95a2b6bfeb3ea1cb68d1751b1dfce2b05adc2a72c488d77a780db06ab867 \ - --hash=sha256:d793c5b4d2b4c668524cd1651404cfc798d40694c759aec997e196fe9729ec60 \ - --hash=sha256:d96162140bb819814428800934c7b71b7bffe81fb6da2d6abc1dcca31741eca3 \ - --hash=sha256:e581f75d5c0b15669139ca1c2d3e23a65bb90e3c06ba9d9ea194c377c726a904 \ - --hash=sha256:ea8dfc99689240e61fb21b5fc2828f68b90abf7777d057b62d3166b7c1543c4c +regex==2026.6.28 \ + --hash=sha256:03376d60b6a11aecb88a79fa2be06b40faa01c6693bc31ef69435cd4818b9463 \ + --hash=sha256:0ab0d5344311fc8e8667078942056c3b9c9b4a4b1cc99f2eb8a5af54554f4acc \ + --hash=sha256:0c31665c0deb5c111557a1cac8c27bd5629e2f9e7fd5058900a03576c33b601c \ + --hash=sha256:0e6cb5a61486f9062397d2e189573b39d38ecfaed698fd9fb6e2756a8ebb8762 \ + --hash=sha256:0f09f62e450cc2f113018cc8412aeea3a120a04e1ca7e801a0d441583f9a3b06 \ + --hash=sha256:11251768cc23f097dd61b18f67966e70f74da822784d17e12a444eb6b29d4288 \ + --hash=sha256:1484bdd6fba28422df9b5ebb04055b2e1b680e8e4f08490bb21ff0f3cc50d0ab \ + --hash=sha256:17c077586770f67e05bbffeba07fbee6b2b22244f4d4caf8d94e59d574befe04 \ + --hash=sha256:17eddca4e8ea9af0b5739314776cdf0172a49731ab61f2e1ea66e066ddd46c97 \ + --hash=sha256:189dbf9fc4252d9f1352bf4bd1bef885edb6cc4b7341df202a65f821aaa3891c \ + --hash=sha256:1e164ace4dbab5c6ad4a4ac7c41a2638fe226d0c770a86f2eb041f594bac6ee7 \ + --hash=sha256:1e693940a3b9e6d6e4dc2a54ecaa74b74934f77af1ef95f518a74261ef7cc1bc \ + --hash=sha256:2097591101d70bcc108af64c46f6066bb698ee067fec5f75beac0be317639311 \ + --hash=sha256:20f4d87702702aa1d572721e146f301660c50eef6fd6cb596e48a22b0ace17db \ + --hash=sha256:234a51e20ebc18ab83b2c0600cf28f2e884560a0e00f743878f0b7d8e7c4cf03 \ + --hash=sha256:23f7e0cc60c72486b42a685f1ff4eec90d50d4fb05e4f9c7d5363b03aa02600d \ + --hash=sha256:28f9e6c28f9b90f6f784595a33240a57e181e61b6ee3dc259b25c61e356d1aa3 \ + --hash=sha256:2e27727fba075f1e4409416d2f537d4c30fc11f012ea507f7bd74d3e19ecb57a \ + --hash=sha256:3169a3159e4d99d9ae85ff0ed90ef3b8906cc3152653b6078b842ace6c8f72c3 \ + --hash=sha256:31d7538a614b5842bf53ce329d07b43f97754ca7e6db8d69f347e071bce1c953 \ + --hash=sha256:3527a72adcbe9e3600f1553b497d397c1a371d227580d41d96c3c5964109b65c \ + --hash=sha256:37294d3d7ddb64c7e89184b2894e0f8f0a19c514bc59513d71fe692c3a8d5fc6 \ + --hash=sha256:378a71d861fc7c8806b04ac5b133d53c0e774f92f5d9663a539872d3fa2b0417 \ + --hash=sha256:3bd630a8dba06b55254ea5ee862194edab52ec783100d2ef1cd15a9c512fee27 \ + --hash=sha256:3c60b297292e7e1ef5d02a4759f9e452ee4c8bb95e168d8fd0b5db01bd806f9f \ + --hash=sha256:3cb4b6c5cb3060cc31efdc1fbb27c25fb9b29044afd87e40601a1c4d9db54342 \ + --hash=sha256:3f15020f0b69cafe57baa067ff65b29acef68ff6b1670a53bef1ca11d708e02d \ + --hash=sha256:3f6316f258bc7e6c9c2acbe9954947bbd397a81be3742a637a555f1855d6618d \ + --hash=sha256:40455e6840dc4e96a6fe50f4cedc957de2752c954d91e789812be55d49be199a \ + --hash=sha256:418208ea0af51cfed4f46eb9b1ea7cfc990ca284f0084ecbd951460fb089421e \ + --hash=sha256:4303ebe16b74eeb3fe2715745023266fea92fd44a23f3e7bb2fb48c7a7bbc195 \ + --hash=sha256:43248fe4c0ab8fbb223588a0795b11268940072c97bba30ea8f9b49d8cdfde34 \ + --hash=sha256:4cc199874ecd6267a49b111052250825bfe19b5101b23b2ba80f54efa3e0994e \ + --hash=sha256:4d80c798b0eec6ea3d45f8816a1e8886c5664615d347d89e8c075b576a1b5a5d \ + --hash=sha256:4da6f6a72f8700b97a1a765e837fb7d5750bfd9f13acea7bae498f573e3a70a8 \ + --hash=sha256:4dfd1331c49233998d84fc5f1f4436cf7a435a7655f6cf0f490229bb5c7254e5 \ + --hash=sha256:51e952c8783eabd4706d0f63922f219bcfc1bef9b8cb35941c0d1a0396578858 \ + --hash=sha256:530b5c223b9ca5dd8370ac502e080aee0e4ded32be987c6564b425fb5523d581 \ + --hash=sha256:56b856b70b96c381d837f609eee442a1bd320cd2159f5c294b679552fb1a7eaf \ + --hash=sha256:56f05194c4843957dd8b3af87eb0c52d8cf0509e7f18e172d727f5f8ff840646 \ + --hash=sha256:5977295b0a74e8241df8a4b3b27b12412a831f6fa32ee8b755039592cd768c3d \ + --hash=sha256:5f2c1682b67ad5d2376498f2a5a2a8f782fa2e4a06d0465b5e357799806e8a20 \ + --hash=sha256:64e142eb55e84868087da1375d7c36ff97d55010951849f515322a91d5fef1b4 \ + --hash=sha256:695873e0ea8d3815ea9e92e2c68faf039cc450e2c0a62a31afe2049eb11be767 \ + --hash=sha256:697f103104f5872d64078d8eeac59979960be8ee76115a2d3f31096312e2a400 \ + --hash=sha256:6bf295f2c59de77d1ea7de053607ae4dc9ceb3d57bbb6c7ec51ef4acc4ccff94 \ + --hash=sha256:6de82c268e5d101ee9e3ffd869924aa9a371e3a21e752cf4fa17b6ce50d219f7 \ + --hash=sha256:700fc6a7844bb2c4149292ac79d1df8841a00acd4d45cd32c1ebc7bcc1fd0da8 \ + --hash=sha256:70710927033af3b54369f17aaba1343b97a23d0b1aa994fa1512b08b1b8c136a \ + --hash=sha256:714d2b1aa29beef0ddfcdc72ad0771c05326551a8bb0680b0ddf74bfaad87387 \ + --hash=sha256:731ea12d5aeb2577eaef2393d6428b995f76eb35f68a89e03e15a97719d1de19 \ + --hash=sha256:7635fa2cddb917a6bbfac7890602573d2d8c4e470703b0640e6f86a988817ec3 \ + --hash=sha256:76493755f79a88d5ed2c9e63a41d3c05997e0a7ffbe76ed8c4ded8be35b8b14c \ + --hash=sha256:7b15c437bc4604f03ceb3f8d37eae2f8930e320e1bc556b259848c639d9eec1a \ + --hash=sha256:7bb96c13d6cf5880d31bbef84ca701a64d738aa491c2b79975cc33f8ad00a31e \ + --hash=sha256:80c7adf1ef647f6b1e8aa2ca280e517174cd08bdf7a2e412cdfb68bd6a0917cb \ + --hash=sha256:81cc5793ad33a10444445e8d29d3c73e752c8fb2e120772d70fcb6d41df40fe1 \ + --hash=sha256:8b92366d9c8bba9642989534073662abdd9b41faf7603a7ae71597833f3b88f0 \ + --hash=sha256:8e0ed273ecd1a89be84466c1749bfe58609cc2a32b5d5e05006c4625ba96411b \ + --hash=sha256:8e2fae6bb883648346f84db270dc9aafc29d8e895f62b88a75ccc83b09519820 \ + --hash=sha256:90581684565a93f7258af1e5d3f41ef20d7d7c61f2a428183a342bcb65485e38 \ + --hash=sha256:9277a4c6503390aa39cb4483b87ec0384faee0850a23b5cea33d008b5d8d83f1 \ + --hash=sha256:94f06cdcd6421f8e194ad312ea608020381250df9b8a57661c1b57e9e5273878 \ + --hash=sha256:9c26a47770d30a0f85c01e261d2a3ebc342c4af6fd666dbd8c1fe4cbf3adf726 \ + --hash=sha256:9cfcd4b0bdcf768c498415c170d1ed2a25a99bf0b65fa253bbd02f68ceba6475 \ + --hash=sha256:a043f5770e82283a22aed4cefef1a4e0f9dd8fd7184cb6ce0ad2e579e2134a9e \ + --hash=sha256:a361feeaf1b6ba1df060f2ff5c5947092edf537a35ce78e76387ac56d3e0f4a4 \ + --hash=sha256:a644f6408692812f5ead82519eed680e08d5d546fddbd9f7d9514e3c73899aa5 \ + --hash=sha256:a71b51dd08b9b62f055fafab3dee8af8bd2ec81b373a44caef18d6c5ca28f43a \ + --hash=sha256:a7cf03c87f7b9cbc25a8894cf9be83818406677b6b391b003ec7c884923387b5 \ + --hash=sha256:aa084684e6d2078bf6139e374d1fc2af5ddc1ac7122759a2db716d68169f6fd0 \ + --hash=sha256:abb4daabe7be63273787a62dfd6164dadf8f7a63fbec3d2730e5e5e7126d858c \ + --hash=sha256:ad5c67786145ec28a71a267d9f9d92bdc8d70d65541eea852c253f520a01f918 \ + --hash=sha256:ad73ecf20c1ef5c975639f8bf845a9370fcf7dada7edc1e3b0bca20e2f8202f6 \ + --hash=sha256:b15859e3908544fb99cf47341dcf0bfd089147d258c4c4d8a29e5b087f8085cb \ + --hash=sha256:b295a83426e0e44e9e60fde99789e181bd26788a1890ae7fe2a24c69bb6246ca \ + --hash=sha256:b77207e3cee13086f1906a6a2a12b41244c577e8ad9370d4b35ae1d548d354f3 \ + --hash=sha256:b83932645630965fd860fdb70ebbf964bf3e8007f08851ea424d01f8d35454a8 \ + --hash=sha256:b916a10431494ef4b4d62c6c89cab6426af7873125b8cd6c15811bf5fc58eec8 \ + --hash=sha256:bf54bc693fc4e0530e666ba5ec4bcba14dbe8f66b7cfc15c27317d1a6e40b9a5 \ + --hash=sha256:bfc9677982c914d9085b8e1c3b3ae6e88f139fb56531c2416d6c8f338093c22b \ + --hash=sha256:c0013958f427bd82509a186b9ff206d66cb8d60a81fc797a4c717afd18c5b0ba \ + --hash=sha256:c10f2c5a55ab3dd8318d8ad5f11b530e2691c0edebebde7713066f484902c3fb \ + --hash=sha256:c4ac65f3e3a99fd8f3a4a74e7a6610acd1ce9dfe9b8a03d346a4922380d68aeb \ + --hash=sha256:c6e6f790d01380a74ad564f216c533b86504afb61bf66f2b2e11e7f1a3e287a7 \ + --hash=sha256:c91487a917edd48a1ea646fdf60d7936d304f0e686fa7ea8326e47efca51d816 \ + --hash=sha256:cadea12805a1bce0b091c302b814207be26fb60a9c0e7f9ad2f9e21790a429fe \ + --hash=sha256:cc579c91fb4605773483a8d940b136bcc5b854fff44fa14a1572a038f46563f1 \ + --hash=sha256:d98b639046e51c5de64d9f77351532105e99ca271cb6f7640e1f903d6ab63032 \ + --hash=sha256:debe623e09cee97ef9404575e936c610aac9bb08358c5099aaef14644a6871f2 \ + --hash=sha256:e128feaf65bf3d9eb91bec92322a8f7e4835e9c798f3e9ea4b69f4def85620e3 \ + --hash=sha256:e18225243250a1f7d7e5e5d883f3b96465cd79031acf5c6db902b7025f2125d9 \ + --hash=sha256:e4466b8641e00c697aab5a73150150d2b2ea96b131c595691f42031abafd9f4d \ + --hash=sha256:e5efbc1af38f97e300d43028e5a92e752d924bcfb7f465d8669d5d5a6e78c233 \ + --hash=sha256:e7c42be203d84ecf7d487ff23f8a61ef0eb0534fa0fc317a2fce8c065d20618f \ + --hash=sha256:e8184b4e2fdaf9cdfe77e38f15a4d9dc149168c9c29eb0ea17c5481d3bb80546 \ + --hash=sha256:e81f1952355042e517dc9861ce65c676e4a098f42402993c40461786d1f794d4 \ + --hash=sha256:e86e91a2664f44c3a4e363a7d78fb17c27d5046882e30ea5a877f5e89b28d2ba \ + --hash=sha256:eacb79625323d9f7e7925366b917f492b8356fad58f5dc4fa12ff8c21d8f4ca9 \ + --hash=sha256:ec2b2ad00ab8c16a2798cc8db80c53c4d5b8b3a2441f6cbaef06625f5ca25854 \ + --hash=sha256:ec9689392f7494ff4e3f8e7e8522f9158f11023f337eaaf04a64542fc45bbf26 \ + --hash=sha256:ecd1638b1c2db1f2d01c182a4b0d3e2e88b0e99910320a745c1727ee3638ddab \ + --hash=sha256:ed7b30185ee3f8b9b053b0be567b4d226016e2afbebc17fde1c6a4580937b688 \ + --hash=sha256:ede8d8e53b6dde0a50f7eca902f0af76d87ab02a55aba7542da68ae3e5dfe83d \ + --hash=sha256:f1758df6fdd8c800620a5638958720e8a635e1da49a2f09df2dd63e94a24ec4a \ + --hash=sha256:f1da438e739765c3e85175ede05816cbede3caaacb1e0680568bda6119bfdfca \ + --hash=sha256:f5561e47bbe2b75373b695326507743fcdd4d2cc7f5022312024ccf39fa094e0 \ + --hash=sha256:f5fbaef40c3e9282ccee4b075f5600a0d858aa0c34147732f1baa69c8188a95d \ + --hash=sha256:f6710f512c57b84f127a23d0f59560a03b64136eff419ae1be5ab557577fe5e3 \ + --hash=sha256:f74675ab76ab1d005ffba4dee308e53e89efc22be6e9f9fae5b539a3f81bdff2 \ + --hash=sha256:f7c032b0c8a73739ff8ff1aaf30c281fa19c17bf7f1543256c8507390db7807c \ + --hash=sha256:fbd2ded482bf99e6651992bbfcde460272724d4bbc49ef3d6b46d9312867ec84 \ + --hash=sha256:fc1eddc25ad23c0f1344ab280d961ac595ead48292d7c779497975942373f493 \ + --hash=sha256:ff0f41a00f23ea5054acb61901380c41813d813eee3f80f800995710bcc52ecd # via dateparser requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ @@ -1534,9 +1675,9 @@ rsa==4.9.1 \ # via # ocotilloapi # python-jose -scramp==1.4.9 \ - --hash=sha256:a05477ccb8c27c28b551ea53c48f3e0ccadd8bb60a25dd9f4efa02194372bf24 \ - --hash=sha256:e8640d915ab4109085b20c464bd7fa664a777fd7f361f90cd2af36e978839614 +scramp==1.4.10 \ + --hash=sha256:084a1d2784a2399ca5021209b490120458882e85e03105c338446ccfe19bee1e \ + --hash=sha256:e187fe49290718406cdaf2f1b56507965e8d9f5f458f478ef946a811eeea382e # via # ocotilloapi # pg8000 @@ -1662,9 +1803,9 @@ tinydb==4.8.2 \ --hash=sha256:f7dfc39b8d7fda7a1ca62a8dbb449ffd340a117c1206b68c50b1a481fb95181d \ --hash=sha256:f97030ee5cbc91eeadd1d7af07ab0e48ceb04aa63d4a983adbaca4cba16e86c3 # via pygeoapi -typer==0.26.7 \ - --hash=sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58 \ - --hash=sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a +typer==0.26.8 \ + --hash=sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c \ + --hash=sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e # via ocotilloapi types-pytz==2025.2.0.20250809 \ --hash=sha256:222e32e6a29bb28871f8834e8785e3801f2dc4441c715cd2082b271eecbe21e5 \ @@ -1700,9 +1841,9 @@ tzdata==2025.3 \ # ocotilloapi # pandas # tzlocal -tzlocal==5.3.1 \ - --hash=sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd \ - --hash=sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d +tzlocal==5.4.4 \ + --hash=sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4 \ + --hash=sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15 # via dateparser urllib3==2.7.0 \ --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ @@ -1719,9 +1860,9 @@ uvicorn==0.49.0 \ --hash=sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f \ --hash=sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3 # via ocotilloapi -werkzeug==3.1.6 \ - --hash=sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25 \ - --hash=sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131 +werkzeug==3.1.8 \ + --hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \ + --hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44 # via flask yarl==1.24.2 \ --hash=sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b \ diff --git a/uv.lock b/uv.lock index 5704e2b63..791d52f03 100644 --- a/uv.lock +++ b/uv.lock @@ -133,16 +133,16 @@ wheels = [ [[package]] name = "alembic" -version = "1.18.4" +version = "1.18.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, + { 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" }, ] [[package]] @@ -165,28 +165,28 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.0" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, ] [[package]] name = "apitally" -version = "0.25.0" +version = "0.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, { name = "opentelemetry-sdk" }, { name = "psutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/96/8b97b8bf9ae198015a2da1bc6bae68a71b0fa11166cbccfe6b1db40ff858/apitally-0.25.0.tar.gz", hash = "sha256:8f6a5c015aac9c69d0b3a393ece62378f01a28e47b987a40d8001c36be74adb6", size = 185262, upload-time = "2026-06-06T10:16:02.777Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/fa/2def459fe980d17e115f0668c5e109b490c5e27215e11020e48279dcfda6/apitally-0.25.1.tar.gz", hash = "sha256:2681e925deffbc94eb7fc65e1f0db397df58634ab1d90597be458d69b2185f7b", size = 183962, upload-time = "2026-06-29T13:08:48.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/76/dba5b3d926482e39bd1283221c62468de4aea649d435072fae320e65496e/apitally-0.25.0-py3-none-any.whl", hash = "sha256:395ff0bfc04a238c6b0c843ab0fc957ec5cb634da2e260b1049c14b5beb78020", size = 48134, upload-time = "2026-06-06T10:16:01.734Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/763b8b602a202e58b1baf692f8219094501e722fd0a767468cd4fb2a5699/apitally-0.25.1-py3-none-any.whl", hash = "sha256:8281fa67fb5cae8cd5d84146cd5e2e0851be7b8c5fe27a6605cdaa5065d00483", size = 48492, upload-time = "2026-06-29T13:08:47.17Z" }, ] [package.optional-dependencies] @@ -520,14 +520,14 @@ wheels = [ [[package]] name = "click" -version = "8.4.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -544,7 +544,7 @@ wheels = [ [[package]] name = "cloud-sql-python-connector" -version = "1.20.3" +version = "1.20.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -554,9 +554,9 @@ dependencies = [ { name = "google-auth" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/87/3f424bab34980b7996514d5232134ef868e412a9dcde1ba20345d674f62f/cloud_sql_python_connector-1.20.3.tar.gz", hash = "sha256:4b6f5c376982206fb0e62545c86d23ee49d045f2e71da817a326654cd169149a", size = 44211, upload-time = "2026-05-27T01:38:29.02Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/ca/626917dd95d17eab155ad5f75360ca668928a0df63cc3259041ffd82ffe5/cloud_sql_python_connector-1.20.3-py3-none-any.whl", hash = "sha256:b4732920b5632be946921fa649a6ddeabfed9447a2b8088d55a6b08919ae3b85", size = 50101, upload-time = "2026-05-27T01:38:27.345Z" }, + { 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" }, ] [[package]] @@ -775,7 +775,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.138.0" +version = "0.138.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -784,9 +784,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/58/ff455d9fe47c60abadb34b9e05a304b1f05f5ab8000ac01565156b6f5e43/fastapi-0.138.0.tar.gz", hash = "sha256:d445a4877636ad191e7053e08c9bf98cb921a6756776848400bb773d1740c061", size = 419240, upload-time = "2026-06-20T01:18:05.259Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/a9/9f8f7e00195c29836e9bf58bbbaf579e29878b8a67851efff93d9b6d4eb7/fastapi-0.138.2.tar.gz", hash = "sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8", size = 420423, upload-time = "2026-06-29T12:44:12.556Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/ff/8496d9847a5fedae775eb49460722d3efaa80487854273e9647ae876218c/fastapi-0.138.0-py3-none-any.whl", hash = "sha256:b6f54fd1bd72c80b0f899f172c61a600f6f7af9b43d4d772a018f35624048cb0", size = 126779, upload-time = "2026-06-20T01:18:03.483Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b3/38be2c074bdd0c986340db1d72d7b2321b805b1c5a68069aa00b5d31fd02/fastapi-0.138.2-py3-none-any.whl", hash = "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615", size = 129271, upload-time = "2026-06-29T12:44:13.905Z" }, ] [[package]] @@ -947,15 +947,15 @@ wheels = [ [[package]] name = "google-auth" -version = "2.55.0" +version = "2.55.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/1c/70b23fc52b2bb3c70b379f3bd05c4a60ab3a873e30c6bd21c57e0154848a/google_auth-2.55.0.tar.gz", hash = "sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb", size = 349379, upload-time = "2026-06-15T22:33:16.466Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/f3f4ac177c67bbee8fe8e88f2ab4f36af88c44a096e165c5217accf6e5d3/google_auth-2.55.1.tar.gz", hash = "sha256:fb2d9b730f2c9b8d326ec8d7222f21aef2ead15bf0513793d6442485d87af0a1", size = 349527, upload-time = "2026-06-25T23:39:27.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/71/c0321dc6d63d99946da45f7c06299b934e4f7f7da5c4f14d101bcb39adf1/google_auth-2.55.0-py3-none-any.whl", hash = "sha256:a17cef9dedf98c4ebae2fb0c48c8f75952c877cbc2efe09f329ef16c2783d88a", size = 252400, upload-time = "2026-06-15T22:33:14.992Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1d/f6d3ca1ad0725f2e08a1c6915640748a52de2e66596160a4d53b010cccf0/google_auth-2.55.1-py3-none-any.whl", hash = "sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995", size = 252349, upload-time = "2026-06-25T23:38:52.946Z" }, ] [[package]] @@ -1032,59 +1032,59 @@ wheels = [ [[package]] name = "greenlet" -version = "3.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/8b/befc3cb36965f397d87e86fb3b00e3ec0dc67c1ecb0986d7f54ee528f018/greenlet-3.5.2.tar.gz", hash = "sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c", size = 199243, upload-time = "2026-06-17T20:19:01.317Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/3c/bb37b9d40d65b0741a8b040ca5c307034d0a9822994dff5f825c88dd7a6b/greenlet-3.5.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34", size = 287178, upload-time = "2026-06-17T17:35:25.132Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a6/0c5902393f492f8ceb19d0b5cf139284e3a11b333a049739643b1036b6f8/greenlet-3.5.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a", size = 606900, upload-time = "2026-06-17T18:07:21.692Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7c/42899c31d4b87148ae4e3f87f63e13398824be6241f4dde42ded95768a34/greenlet-3.5.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7", size = 619265, upload-time = "2026-06-17T18:29:44.837Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7e/28f991affb413b232b1e7d768db24c37b3f4d5daecc3f19b455d40bd2dea/greenlet-3.5.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9dc23f0e5ad76415457212a4b947d22ebe4dc80baf02adf7dd5647a90f38bb4e", size = 625044, upload-time = "2026-06-17T18:39:29.046Z" }, - { url = "https://files.pythonhosted.org/packages/d3/52/4ff8c98d3cfe62b4515f8584ae14510a58f35c549cc5292b78d9b7a40b70/greenlet-3.5.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473", size = 616187, upload-time = "2026-06-17T17:39:29.473Z" }, - { url = "https://files.pythonhosted.org/packages/29/05/0cc9ec660e7acff85f93b0a048b6654371c822c884add44c02a465cf70e0/greenlet-3.5.2-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:423167363c510a75b649f5cd58d873c29498ea03598b9e4b1c3b73e0f899f3d5", size = 427322, upload-time = "2026-06-17T18:41:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a6/269c8bf9aefc13361ce1088f0e392b154cb21005de7862e42b5d782b81fd/greenlet-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5", size = 1573778, upload-time = "2026-06-17T18:22:13.497Z" }, - { url = "https://files.pythonhosted.org/packages/1f/9b/391d015cbc6323e81b14c02cf825fdca7e0049c9bb489bf4ac72883118ba/greenlet-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5", size = 1638092, upload-time = "2026-06-17T17:40:08.163Z" }, - { url = "https://files.pythonhosted.org/packages/49/53/5b4df711f4356c62e85d9f819d87966d526d1cfb32bae49a8f7d6fc36ea4/greenlet-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c", size = 239352, upload-time = "2026-06-17T17:38:51.593Z" }, - { url = "https://files.pythonhosted.org/packages/bb/b6/18efc3a329ec035c3f344b8f2b60356451950ddf9b7b64ff00023778a1dd/greenlet-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4", size = 237635, upload-time = "2026-06-17T17:35:36.632Z" }, - { url = "https://files.pythonhosted.org/packages/c7/89/aaafc8e14de4ac882e02ccb963225329b0e8578aba4365e71eb678e45722/greenlet-3.5.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb", size = 287676, upload-time = "2026-06-17T17:33:31.514Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fc/2308249206c12ac70de7b9a00970f84f07d10b3cd60e05d2fbcaa84124e8/greenlet-3.5.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39", size = 653552, upload-time = "2026-06-17T18:07:23.493Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/47730d1f8f1336b9b089237521ed7a26eee997065dcb4cab81cdca333abc/greenlet-3.5.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8", size = 665756, upload-time = "2026-06-17T18:29:46.616Z" }, - { url = "https://files.pythonhosted.org/packages/23/5c/2664d290cbd1fef9eb3f69b5d3bc5aa91b6fa907519298ca6af93a90c6cb/greenlet-3.5.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e9e49d732ee92a189bb7035e293029244aeba648297a9b856dc733d17ca7f0d", size = 669989, upload-time = "2026-06-17T18:39:30.79Z" }, - { url = "https://files.pythonhosted.org/packages/99/69/d6c99db15dc0b5e892ac3cc7b942c8b21f4a9cc3bd9ea0bc3b0f339ffbd4/greenlet-3.5.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163", size = 663228, upload-time = "2026-06-17T17:39:31.073Z" }, - { url = "https://files.pythonhosted.org/packages/42/d4/fcb53fa9847d7fbd4723fbed9469c3869b9e3544c4e001d9d5aa2f66162d/greenlet-3.5.2-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:537c5c4f30395020bb9f48f53146070e3b997c3c75da14011ab732aaa19ce3ef", size = 472888, upload-time = "2026-06-17T18:41:22.511Z" }, - { url = "https://files.pythonhosted.org/packages/4f/88/9e603f448e2bc107c883e95817b980fb9b45ba6aea0299b2e9978124bea2/greenlet-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95", size = 1620723, upload-time = "2026-06-17T18:22:14.817Z" }, - { url = "https://files.pythonhosted.org/packages/11/91/26da17e3777858c16fdb8d020a4c68f3a03cb92f238de8f5351d5d5186e9/greenlet-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317", size = 1684227, upload-time = "2026-06-17T17:40:09.536Z" }, - { url = "https://files.pythonhosted.org/packages/2d/44/b3a11f7aa34cb38f1b7f3df8bcd9fcd09bac9d342c2a2c9b8686c804bcd2/greenlet-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73", size = 240257, upload-time = "2026-06-17T17:35:23.359Z" }, - { url = "https://files.pythonhosted.org/packages/de/e3/3b62145fe917311732041a258adb218248add00542e3131c48bd047fbed5/greenlet-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e", size = 239038, upload-time = "2026-06-17T17:37:56.792Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/d3bad483e9f6cd1848604fdffa32cac25846dd6dfcec0e6f81c790185518/greenlet-3.5.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0", size = 295668, upload-time = "2026-06-17T17:36:02.293Z" }, - { url = "https://files.pythonhosted.org/packages/00/e9/3a7e557b895fd0469b00cd0b2bd498ba950e8bfdf6d7adeecf2c5e4130a6/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c", size = 652820, upload-time = "2026-06-17T18:07:24.95Z" }, - { url = "https://files.pythonhosted.org/packages/78/67/6225d5c5e4afc04be0fd161eec82e4b72017e8a100d222f25d7b42b0140d/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3", size = 658697, upload-time = "2026-06-17T18:29:48.365Z" }, - { url = "https://files.pythonhosted.org/packages/35/ad/9b3058f999b81750a9c6d9ec424f509462d232b58002086fe2ba63b66407/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ee6288f1933d698b4f098127ed17bda2910a75d2807915bd16294a972055d6c", size = 658945, upload-time = "2026-06-17T18:39:32.509Z" }, - { url = "https://files.pythonhosted.org/packages/fa/99/6324b8ef916dcaddccb340b304c992ca3f947614ce0f2685d438187300b8/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de", size = 656436, upload-time = "2026-06-17T17:39:32.509Z" }, - { url = "https://files.pythonhosted.org/packages/92/75/1b6ecd8c027b69ab1b6798a84094df79aab5e69ac7e249c78b9d361dd1fa/greenlet-3.5.2-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:b4cad42662c796334c2d24607c411e3ed82481c1fb4e1e8ec3a5a8416060092e", size = 490529, upload-time = "2026-06-17T18:41:23.954Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ee/f5bf9daac27c5e1b011965f64b5630a32b415daf7381b312943629e12c2a/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8", size = 1617193, upload-time = "2026-06-17T18:22:16.252Z" }, - { url = "https://files.pythonhosted.org/packages/8a/21/b05d5b12715bda92ce27c118d64971d21e9b8f3563ed959a7d271e2d4223/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a", size = 1677512, upload-time = "2026-06-17T17:40:10.771Z" }, - { url = "https://files.pythonhosted.org/packages/b8/97/1b8f1314b868041b327dc1051603e8142b826480cb0ecb8a7b7632aee9c4/greenlet-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32", size = 243145, upload-time = "2026-06-17T17:34:37.502Z" }, - { url = "https://files.pythonhosted.org/packages/36/07/1b5311775e04c718a118c504d7a3a312430e2a1bd1347226aff4774e4549/greenlet-3.5.2-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb", size = 288315, upload-time = "2026-06-17T17:34:34.04Z" }, - { url = "https://files.pythonhosted.org/packages/ed/cc/6abcd2a486b58b9f77b7a93b690d59cb2c11a5906ed2ad4c63c7b9c1113d/greenlet-3.5.2-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682", size = 659130, upload-time = "2026-06-17T18:07:26.354Z" }, - { url = "https://files.pythonhosted.org/packages/f2/12/f4aaad6d3d383233f700ab322568a4f29f2c701a4861d85f4811d99689b2/greenlet-3.5.2-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce", size = 669724, upload-time = "2026-06-17T18:29:50.13Z" }, - { url = "https://files.pythonhosted.org/packages/53/e0/4ce3a046b51e53934eae93d7f9c13975a97285741e9e1fcadf8751314c37/greenlet-3.5.2-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2debcd0ef9455b7d4879589903efc8e497d4b8fb8c0ae772309e44d1ca5e957f", size = 673494, upload-time = "2026-06-17T18:39:34.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/2a/a089811fc31c6bf8742f40a4e73470d6d401cef18e4314eb20dc399b377c/greenlet-3.5.2-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9", size = 668089, upload-time = "2026-06-17T17:39:33.808Z" }, - { url = "https://files.pythonhosted.org/packages/52/e0/9c18721e63445dce02ee67e4c81c0f281626604ff55ae6f7b7f4354d7129/greenlet-3.5.2-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:9558cae989faeab6fbb425cd98a0cfa4190a47fba6443973fbee0a1eb0b0b6c3", size = 479721, upload-time = "2026-06-17T18:41:25.726Z" }, - { url = "https://files.pythonhosted.org/packages/0f/1c/2f47c7d5fcfa98a62b705bf9a0505d86f4563c0d81cab1f7159ff1e743b7/greenlet-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32", size = 1625684, upload-time = "2026-06-17T18:22:17.664Z" }, - { url = "https://files.pythonhosted.org/packages/b9/bf/661dd24624f70b7b32972d7693d0344ecde10278f647d7b828baf739899c/greenlet-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74", size = 1688043, upload-time = "2026-06-17T17:40:12.403Z" }, - { url = "https://files.pythonhosted.org/packages/60/49/d9bde1d15a21296b3b521fe083eb8aabd54ac05d15de9832918f3d639543/greenlet-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a", size = 240531, upload-time = "2026-06-17T17:35:47.448Z" }, - { url = "https://files.pythonhosted.org/packages/7f/4d/86d7768bd53e9907de0333df215c2018cd01a593b3715cbd79aa82dd94b7/greenlet-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9", size = 239579, upload-time = "2026-06-17T17:39:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/92/15/907be5e8900901039bae752fa9a31c03a3c1e064833f35a4e49449184581/greenlet-3.5.2-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c", size = 296697, upload-time = "2026-06-17T17:37:15.887Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/08c57be575c3d6a3c023bbf22144a1c7dc6ed4d134527bb36ded4dbf04a8/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4", size = 656710, upload-time = "2026-06-17T18:07:28.046Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d0/749f917bdc9fc90fceea4aa65fbf6556e617a50714d1496bdc8ad190bb36/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c", size = 662629, upload-time = "2026-06-17T18:29:51.728Z" }, - { url = "https://files.pythonhosted.org/packages/55/87/10776cd88df54d0f563e9e21e98363f2d6af94bedc553b1da0972fa87f80/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9476cbead736dc48ce89e3cd97acff95ecc48cbf21273603a438f9870c4a014", size = 663191, upload-time = "2026-06-17T18:39:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a5/68cefae3a07f6d0093a490cf28ab604f14578f3e60205a2a2b2d5cd70af2/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00", size = 660147, upload-time = "2026-06-17T17:39:35.068Z" }, - { url = "https://files.pythonhosted.org/packages/02/aa/26ddf92826a99d87bfb8fdb8f3a262a6f16495a5d8e579737baa92fb4543/greenlet-3.5.2-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:5930d3946ecae99fa7fc0e3f3ae515426ad85058ebd9bfc6c00cca8016e6206b", size = 498199, upload-time = "2026-06-17T18:41:27.464Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6b/b9156d8397e4750220f54c7c5c34650f1e740a8d2f66eab9cfd1b7b53b69/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1", size = 1621675, upload-time = "2026-06-17T18:22:18.873Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e3/d3250f4fa01c211a93d04e34fded63187e648dbec17b9b1a14d388040593/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f", size = 1680577, upload-time = "2026-06-17T17:40:14.055Z" }, - { url = "https://files.pythonhosted.org/packages/55/ba/eaee8bda4419770d7096b5a009ebff0ab20a2a28cdd83c4b591bfdf36fa9/greenlet-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904", size = 243482, upload-time = "2026-06-17T17:37:34.741Z" }, - { url = "https://files.pythonhosted.org/packages/37/45/f794a81c91e9942c61f9110bd1f9a38a0ea565eab57f8b08cd53d3131e48/greenlet-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f", size = 242062, upload-time = "2026-06-17T17:35:39.814Z" }, +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" }, ] [[package]] @@ -1602,10 +1602,10 @@ requires-dist = [ { name = "aiohttp", specifier = "==3.14.1" }, { name = "aiosignal", specifier = "==1.4.0" }, { name = "aiosqlite", specifier = "==0.22.1" }, - { name = "alembic", specifier = "==1.18.4" }, + { name = "alembic", specifier = "==1.18.5" }, { name = "annotated-types", specifier = "==0.7.0" }, - { name = "anyio", specifier = "==4.14.0" }, - { name = "apitally", extras = ["fastapi"], specifier = "==0.25.0" }, + { name = "anyio", specifier = "==4.14.1" }, + { name = "apitally", extras = ["fastapi"], specifier = "==0.25.1" }, { name = "asgiref", specifier = "==3.11.1" }, { name = "asn1crypto", specifier = "==1.5.1" }, { name = "asyncpg", specifier = "==0.31.0" }, @@ -1616,24 +1616,24 @@ requires-dist = [ { name = "certifi", specifier = "==2026.6.17" }, { name = "cffi", specifier = "==2.0.0" }, { name = "charset-normalizer", specifier = "==3.4.7" }, - { name = "click", specifier = "==8.4.1" }, - { name = "cloud-sql-python-connector", specifier = "==1.20.3" }, + { name = "click", specifier = "==8.4.2" }, + { name = "cloud-sql-python-connector", specifier = "==1.20.4" }, { 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.138.0" }, + { name = "fastapi", specifier = "==0.138.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.31.0" }, - { name = "google-auth", specifier = "==2.55.0" }, + { name = "google-auth", specifier = "==2.55.1" }, { name = "google-cloud-core", specifier = "==2.6.0" }, { name = "google-cloud-storage", specifier = "==3.12.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.2" }, + { name = "greenlet", specifier = "==3.5.3" }, { name = "gunicorn", specifier = "==23.0.0" }, { name = "h11", specifier = "==0.16.0" }, { name = "httpcore", specifier = "==1.0.9" }, @@ -1674,7 +1674,7 @@ requires-dist = [ { name = "pytz", specifier = "==2026.2" }, { name = "requests", specifier = "==2.34.2" }, { name = "rsa", specifier = "==4.9.1" }, - { name = "scramp", specifier = "==1.4.9" }, + { name = "scramp", specifier = "==1.4.10" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.63.0" }, { name = "shapely", specifier = "==2.1.2" }, { name = "six", specifier = "==1.17.0" }, @@ -1686,7 +1686,7 @@ requires-dist = [ { name = "sqlparse", specifier = ">=0.5.5" }, { name = "starlette", specifier = "==1.3.1" }, { name = "starlette-admin", extras = ["i18n"], specifier = "==0.16.1" }, - { name = "typer", specifier = "==0.26.7" }, + { name = "typer", specifier = "==0.26.8" }, { name = "typing-extensions", specifier = "==4.15.0" }, { name = "typing-inspection", specifier = "==0.4.2" }, { name = "tzdata", specifier = "==2025.3" }, @@ -2750,14 +2750,14 @@ wheels = [ [[package]] name = "scramp" -version = "1.4.9" +version = "1.4.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asn1crypto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/68/128a3d133fce87130a6f9266e9d8f131cbd077201c2fa851e2f569f75748/scramp-1.4.9.tar.gz", hash = "sha256:a05477ccb8c27c28b551ea53c48f3e0ccadd8bb60a25dd9f4efa02194372bf24", size = 17178, upload-time = "2026-06-19T14:58:37.403Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/3c/fa5b7b95d29feea7de913c42ce6fe9ed9be21a13c4ee7307ba2a2a78755d/scramp-1.4.10.tar.gz", hash = "sha256:084a1d2784a2399ca5021209b490120458882e85e03105c338446ccfe19bee1e", size = 18123, upload-time = "2026-06-27T08:25:36.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/25/d563dc2822039e9283a106a3cc948149c46d14d85d96f434722000951267/scramp-1.4.9-py3-none-any.whl", hash = "sha256:e8640d915ab4109085b20c464bd7fa664a777fd7f361f90cd2af36e978839614", size = 13690, upload-time = "2026-06-19T14:58:36.071Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f3/fa74bbc0dcd15c624b352525cfd6d19bd7441799fda8573be76145f9a37b/scramp-1.4.10-py3-none-any.whl", hash = "sha256:e187fe49290718406cdaf2f1b56507965e8d9f5f458f478ef946a811eeea382e", size = 13943, upload-time = "2026-06-27T08:25:34.755Z" }, ] [[package]] @@ -2970,7 +2970,7 @@ wheels = [ [[package]] name = "typer" -version = "0.26.7" +version = "0.26.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -2978,9 +2978,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" }, + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, ] [[package]] From 1cd9fa0d9203525b4c1d1dfbe9942f06d9402dfd Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Mon, 29 Jun 2026 17:37:34 -0600 Subject: [PATCH 115/160] test(ogc): add Sprint 1 cleanup feature spec Add a new BDD feature file covering OGC cleanup Sprint 1 behavior. Tag scenarios by concern, sprint scope, priority, and action item so the spec captures both expected behavior and how cases should be grouped for execution and tracking. --- tests/features/ogc-cleanup-sprint1.feature | 397 +++++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 tests/features/ogc-cleanup-sprint1.feature diff --git a/tests/features/ogc-cleanup-sprint1.feature b/tests/features/ogc-cleanup-sprint1.feature new file mode 100644 index 000000000..2049023bc --- /dev/null +++ b/tests/features/ogc-cleanup-sprint1.feature @@ -0,0 +1,397 @@ +Feature: OGC Feature Layer Cleanup — Sprint 1 + As an OGC API consumer + I want the Ocotillo API feature layers to be accurately filtered, correctly named, and reliably configured + So that I can depend on the API for scientific and operational use + + # Sprint 1 scope: A1, A2, A3, A4, A6, A11, A13, A16, A17, A18, A22, A23 + # + # Deferred to Sprint 2: + # A5 — int(None) runtime warning in pygeoapi/api/itemtypes.py + # A7 — Level 1 display title pass (non-breaking naming update) + # A8 — Layer ID renames (Level 2 vs Level 3 decision pending) + # A9 — Publication predicate policy per layer family (domain-owner sign-off required) + # A10 — Per-layer SQL publication filters (depends on A9) + # A12 — Sentinel date nulling in chemistry layers + # A14 — Group A view template split to remove non-well schema bleed + # A15 — Materialized view refresh schedule documentation + # A19 — Sparse Group A layer hiding + # A20 — Extended test coverage for all 22 configured collections + # A21 — Separate database roles for public and internal OGC access + + Background: + Given the Ocotillo API is running + + # --------------------------------------------------------------------------- + # A1 — Apply release_status = 'public' filter to all OGC views + # --------------------------------------------------------------------------- + + @backend @ogc-exposure @sprint-1 @high-priority @A1 + 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 + 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 + Then each ogc_* view returns the same count of public records as before the migration + And each ogc_* view returns the same count of private records as before the migration + 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 + 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: + | layer-id | + | water_wells | + | springs | + | perennial_streams | + | meteorological_stations | + | diversions_surface_water | + | lakes_ponds_reservoirs | + | other_things | + | water_well_summary | + | depth_to_water_trend_wells | + | water_elevation_wells | + | major_chemistry_results | + | 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 + + @backend @ogc-exposure @sprint-1 @high-priority @A1 + 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 + Scenario: The 4 already-consistent layers are unaffected by the migration + Given the following layers were already filtering correctly before the migration: + | layer-id | + | ephemeral_streams | + | rock_sample_locations | + | soil_gas_sample_locations | + | outfalls_wastewater_return_flow | + When the Sprint 1 migration is applied + Then each of those layers returns the same feature count as before the migration + + # --------------------------------------------------------------------------- + # A2 — Replace OGC server metadata placeholders in pygeoapi-config.yml + # --------------------------------------------------------------------------- + + @backend @ogc-infrastructure @sprint-1 @high-priority @A2 + 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 + + @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 + 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 | + + # --------------------------------------------------------------------------- + # A3 — Fix broken README example URLs + # --------------------------------------------------------------------------- + + @backend @ogc-infrastructure @sprint-1 @high-priority @A3 + Scenario Outline: README example URLs return valid GeoJSON + Given the README example URLs reference the water_wells collection + When a client requests "" + Then the response HTTP status is 200 + And the response Content-Type is "application/geo+json" + + Examples: + | url-path | + | /ogcapi/collections/water_wells/items?limit=5 | + | /ogcapi/collections/water_wells/items?datetime=2020-01-01/2024-01-01 | + + # --------------------------------------------------------------------------- + # A4 — Fix brittle SQL filter in actively_monitored_wells + # --------------------------------------------------------------------------- + # 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. + + @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 + When a client requests features from the actively_monitored_wells layer + Then the feature count is 322 + + @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" + + @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 + 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 + + # --------------------------------------------------------------------------- + # A11 — Stand up authenticated internal OGC mount at /ogcapi-internal + # --------------------------------------------------------------------------- + + @backend @ogc-infrastructure @sprint-1 @high-priority @A11 + 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 + 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 + 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 + 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 + 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 + 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_" + + # --------------------------------------------------------------------------- + # A13 — Add last_observation_date column to Group A view template + # --------------------------------------------------------------------------- + + @backend @ogc-data-currency @sprint-1 @medium-priority @A13 + 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 | + | water_wells | + | springs | + | perennial_streams | + | meteorological_stations | + | ephemeral_streams | + | rock_sample_locations | + | diversions_surface_water | + | 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 + + @backend @ogc-data-currency @sprint-1 @medium-priority @A13 + 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 | + | water_wells | + | springs | + | perennial_streams | + | meteorological_stations | + | ephemeral_streams | + | rock_sample_locations | + | diversions_surface_water | + | 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 + + @backend @ogc-data-currency @sprint-1 @medium-priority @A13 + 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 | + | water_wells | + | springs | + | perennial_streams | + | meteorological_stations | + | ephemeral_streams | + | rock_sample_locations | + | diversions_surface_water | + | 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 + + # --------------------------------------------------------------------------- + # A16 — Hide avg_tds_wells and latest_depth_to_water_wells from public catalog + # --------------------------------------------------------------------------- + + @backend @ogc-data-currency @sprint-1 @medium-priority @A16 + 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 + 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 + 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 + Then the materialized view for avg_tds_wells exists in the database schema + And the materialized view for latest_depth_to_water_wells exists in the database schema + + # --------------------------------------------------------------------------- + # A17 — Hide locations layer from the public catalog + # --------------------------------------------------------------------------- + + @backend @ogc-data-currency @sprint-1 @medium-priority @A17 + 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 + 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 + Then the locations table still exists + + # --------------------------------------------------------------------------- + # A18 — Remove other_things from the public catalog + # --------------------------------------------------------------------------- + + @backend @ogc-naming @sprint-1 @medium-priority @A18 + 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 + Given the other_things view has at least one reference in the application codebase + When the cleanup is applied + Then the other_things backing view still exists in the database schema + + # --------------------------------------------------------------------------- + # A22 — Verify NULL measuring_point_height assumption for water level layers + # --------------------------------------------------------------------------- + + # A22 policy gate: an ADR must be written confirming whether NULL + # measuring_point_height is treated as zero or excluded from calculations. + # Tracked in ticket — not enforced as a Behave scenario. + + @backend @ogc-data-currency @sprint-1 @medium-priority @A22 + Scenario: Layer descriptions are updated when the NULL-as-zero assumption is confirmed + Given the NULL measuring_point_height handling policy is documented as "treat as zero (ground surface level)" + When the layer descriptions are updated + Then each of the following layer descriptions documents the zero assumption: + | layer-id | + | water_well_summary | + | depth_to_water_trend_wells | + | water_elevation_wells | + And each of those layers is reclassified to production-ready status + + @backend @ogc-data-currency @sprint-1 @medium-priority @A22 + Scenario: Null handling logic is corrected and matviews rebuilt when assumption is not confirmed + Given the NULL measuring_point_height handling policy is documented as "flag as unverified and exclude from depth calculations" + When the corrected view definitions are applied + Then the null handling logic for measuring_point_height is corrected in each of the following views: + | layer-id | + | water_well_summary | + | depth_to_water_trend_wells | + | water_elevation_wells | + And all affected materialized views are rebuilt and refreshed + And the depth-to-water value for a well with NULL measuring_point_height is null or absent + + # --------------------------------------------------------------------------- + # A23 — Bump advertised spatial extent to include northern border features + # --------------------------------------------------------------------------- + + @backend @ogc-geometry @sprint-1 @low-priority @A23 + Scenario: Spatial extent northern latitude boundary is updated in config + Given the service configuration has been updated with the corrected spatial extent + When the service configuration is loaded + Then the advertised northern latitude boundary is "37.10" + + @backend @ogc-geometry @sprint-1 @low-priority @A23 + Scenario: Landing page reflects the updated spatial extent + When a client requests the /ogcapi landing page + Then the spatial extent northern boundary in the response is "37.10" + + @backend @ogc-geometry @sprint-1 @low-priority @A23 + Scenario: Collections response reflects the updated spatial extent + When a client requests /ogcapi/collections + Then the spatial extent northern boundary in the response is "37.10" + + @backend @ogc-geometry @sprint-1 @low-priority @A23 + Scenario: Extent config update includes previously excluded border features + Given the spatial extent northern latitude boundary is "37.10" + When a client requests items from the "water_wells" layer + Then the response includes the feature with id "4826" + And that feature's latitude is between "37.00" and "37.10" + + @backend @ogc-geometry @sprint-1 @low-priority @A23 + Scenario: Extent config update does not modify feature data for existing layers + Given the geometry and attributes of feature "4826" from "water_wells" are recorded as a baseline + And the spatial extent northern latitude boundary is "37.10" + When a client requests the feature with id "4826" from the "water_wells" layer + Then the feature geometry coordinates match the recorded baseline + And the feature attributes match the recorded baseline From 2d2db87f88ee8d74a93e44dcfbbf83fd7b052267 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Tue, 30 Jun 2026 15:26:13 -0600 Subject: [PATCH 116/160] fix: apply isinstance(user, dict) guard to model_adder and model_deleter Same fix as model_patcher: the remaining `if user:` and `if user and resource_type:` checks in model_adder and model_deleter also pass for a boolean True, which would cause a TypeError if notify_edit_event were ever reached. Consistent with the fix already applied to model_patcher and observation_model_patcher. --- services/crud_helper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/crud_helper.py b/services/crud_helper.py index 2c7f3d41b..6233a7c9b 100644 --- a/services/crud_helper.py +++ b/services/crud_helper.py @@ -66,7 +66,7 @@ def model_adder(session, table, model, user=None, **kwargs): session.commit() session.refresh(obj) - if user: + if isinstance(user, dict): resource_type = _resource_type_for_item(table, obj) if resource_type: label = _resource_label(obj) @@ -158,7 +158,7 @@ def model_deleter( session.delete(item) session.commit() - if user and resource_type: + if isinstance(user, dict) and resource_type: notify_edit_event( user, EditEvent( From 0e8cda1a0c3eaa6e617fb5b3368eabbbdd58b12a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 1 Jul 2026 09:26:09 -0500 Subject: [PATCH 117/160] feat(api/asset): add new list unassociated assets endpoint --- api/asset.py | 24 ++++++++++++++++++++++++ tests/test_asset.py | 15 +++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/api/asset.py b/api/asset.py index d7d2b62d6..fff96717b 100644 --- a/api/asset.py +++ b/api/asset.py @@ -468,6 +468,30 @@ def transformer(records: list[Asset]): return paginate(query=sql, conn=session, transformer=transformer) +@router.get("/unassociated") +async def list_unassociated_assets( + user: viewer_dependency, + session: session_dependency, +) -> CustomPage[AssetResponse]: + """ + List assets that are not associated with any Thing. + """ + sql = ( + select(Asset) + .outerjoin(AssetThingAssociation) + .where(AssetThingAssociation.asset_id.is_(None)) + .order_by(Asset.id) + ) + + def transformer(records: list[Asset]): + from services.gcs_helper import add_signed_url + + bucket = get_storage_bucket() + return [add_signed_url(asset, bucket) for asset in records] + + return paginate(query=sql, conn=session, transformer=transformer) + + @router.get("/{asset_id}") async def get_asset( user: viewer_dependency, diff --git a/tests/test_asset.py b/tests/test_asset.py index 6266e7c72..1b54a4e4d 100644 --- a/tests/test_asset.py +++ b/tests/test_asset.py @@ -306,6 +306,21 @@ def test_get_assets_thing_id(asset_with_associated_thing, water_well_thing): ) +def test_get_unassociated_assets(asset, asset_with_associated_thing): + with patch( + "api.asset.get_storage_bucket", + return_value=MockStorageBucket(), + ): + response = client.get("/asset/unassociated") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == asset.id + expected_signed_url = MockBlob().generate_signed_url() + assert data["items"][0]["signed_url"] == expected_signed_url + assert data["items"][0]["id"] != asset_with_associated_thing.id + + def test_get_asset_by_id(asset): response = client.get(f"/asset/{asset.id}") assert response.status_code == 200 From 8b3d67d39d13338db71cebb6421073a012f88ec3 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 1 Jul 2026 14:12:32 -0500 Subject: [PATCH 118/160] docs: add code comments to unassociated assets endpoint --- api/asset.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/asset.py b/api/asset.py index fff96717b..f3261f39c 100644 --- a/api/asset.py +++ b/api/asset.py @@ -483,6 +483,9 @@ async def list_unassociated_assets( .order_by(Asset.id) ) + # Signed URLs are generated for thumbnail display on the frontend. + # The frontend paginates this endpoint and requests only 10 assets at a time, + # which limits GCP IAM calls and keeps signed URL generation manageable. def transformer(records: list[Asset]): from services.gcs_helper import add_signed_url From d227c52f2a93f08e5cd4c109fa38120ddbd4d10a Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Thu, 2 Jul 2026 10:23:18 -0600 Subject: [PATCH 119/160] feat(lexicon): add new 'Spanish Stirrup Rockshop' organization term Necessary for importing well inventory records with Spanish Stirrup Rockshop as the organization. --- core/lexicon.json | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/core/lexicon.json b/core/lexicon.json index eb4c1f1a9..890ebc486 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -2258,27 +2258,37 @@ "definition": "Defines if a datalogger can or cannot be installed at the well." }, { - "categories": ["status_value"], + "categories": [ + "status_value" + ], "term": "Open", "definition": "The well is open." }, { - "categories": ["status_value"], + "categories": [ + "status_value" + ], "term": "Open (unequipped)", "definition": "The well is open and unequipped." }, { - "categories": ["status_value"], + "categories": [ + "status_value" + ], "term": "Closed", "definition": "The well is closed." }, { - "categories": ["status_value"], + "categories": [ + "status_value" + ], "term": "Datalogger can be installed", "definition": "A datalogger can be installed at the well" }, { - "categories": ["status_value"], + "categories": [ + "status_value" + ], "term": "Datalogger cannot be installed", "definition": "A datalogger cannot be installed at the well" }, @@ -4207,6 +4217,13 @@ "term": "Slash Triangle Ranch", "definition": "Slash Triangle Ranch" }, + { + "categories": [ + "organization" + ], + "term": "Spanish Stirrup Rockshop", + "definition": "Spanish Stirrup Rockshop" + }, { "categories": [ "organization" @@ -8452,4 +8469,4 @@ "definition": "Data were not field checked but are considered reliable" } ] -} +} \ No newline at end of file From 0ac283dc9148ea5179fb0c5b1956c975df20acc2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:06:10 +0000 Subject: [PATCH 120/160] build(deps): bump astral-sh/setup-uv from 8.2.0 to 8.3.0 in the gha-minor-and-patch group (#752) Bumps the gha-minor-and-patch group with 1 update: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv). Updates `astral-sh/setup-uv` from 8.2.0 to 8.3.0
    Commits
    • d31148d Strip environment markers from detected uv dependency pins (#938)
    • 17c3989 Fix cache keys for Python version ranges (#937)
    • 3cc3c11 chore(deps): roll up Dependabot updates (#936)
    • 9225f84 chore(deps): bump release-drafter/release-drafter from 7.3.1 to 7.4.0 (#924)
    • fc16fa3 chore(deps): bump actions/checkout from 6.0.2 to 7.0.0 (#926)
    • a1a7345 ci: call docs update workflow from release (#933)
    • a5e9cbf docs: update version references to v8.2.0 (#932)
    • c5680ec chore: update known checksums for 0.11.26 (#930)
    • c86fe4e Add a threat model for setup-uv (#923)
    • 224c887 chore: update known checksums for 0.11.25 (#929)
    • Additional commits viewable in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=astral-sh/setup-uv&package-manager=github_actions&previous-version=8.2.0&new-version=8.3.0)](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/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 0985f7a70..a823ae1a1 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.2.0 + uses: astral-sh/setup-uv@v8.3.0 with: version: "latest" diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 357dba3bc..e2fa929e3 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.2.0 + uses: astral-sh/setup-uv@v8.3.0 with: version: "latest" diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index c10ff6d64..7004c5b60 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.2.0 + uses: astral-sh/setup-uv@v8.3.0 with: version: "latest" diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml index 3181a808a..0131dd7ad 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.2.0 + uses: astral-sh/setup-uv@v8.3.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.2.0 + uses: astral-sh/setup-uv@v8.3.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 b6d130a7c..344177723 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@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v4 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v4 with: enable-cache: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c49dc441e..72f35451e 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.2.0 + uses: astral-sh/setup-uv@v8.3.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.2.0 + uses: astral-sh/setup-uv@v8.3.0 with: enable-cache: true cache-dependency-glob: uv.lock From 0398dad1ae63013166bf69e1c81c92ea0692f4e1 Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 6 Jul 2026 10:00:37 -0600 Subject: [PATCH 121/160] fix(deploy): prevent App Engine request starvation under burst load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prod cycled between 0-1 instances and returned site-wide 500/503 with "Request was aborted after waiting too long to attempt to service your request." Requests died in the pending queue (blank instanceId, ~2ms latency), not in app code — App Engine never scaled out past one instance despite max_instances=10. Root cause: the scheduler had no visibility into real per-instance concurrency (gunicorn -w 4), so it kept routing bursts to a single saturated instance instead of spinning up more. min_instances=0 added cold-start pile-ups on top. - app.template.yaml: add max_concurrent_requests: 6 so the scheduler scales out before an instance saturates (2-worker headroom for bursts) - CD_production.yml: MIN_INSTANCES 0 -> 1 to kill cold-start pile-ups - CD_production.yml: gunicorn -w 4 -> -w 8 for more concurrency per F4 Co-Authored-By: Claude Opus 4.8 --- .github/app.template.yaml | 1 + .github/workflows/CD_production.yml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/app.template.yaml b/.github/app.template.yaml index 3abdacb68..f2b4e92bc 100644 --- a/.github/app.template.yaml +++ b/.github/app.template.yaml @@ -8,6 +8,7 @@ inbound_services: automatic_scaling: min_instances: ${MIN_INSTANCES} max_instances: ${MAX_INSTANCES} + max_concurrent_requests: 6 handlers: - url: /.* secure: always diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index a823ae1a1..5a0acce62 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -148,8 +148,8 @@ jobs: run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api" - export ENTRYPOINT="gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app" - export MIN_INSTANCES="0" + export ENTRYPOINT="gunicorn -w 8 -k uvicorn.workers.UvicornWorker main:app" + export MIN_INSTANCES="1" envsubst < .github/app.template.yaml > app.yaml - name: Deploy to Google Cloud From 6d29bb1b1dec3db2667fe0d5630873cdb79695f8 Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 6 Jul 2026 10:34:40 -0600 Subject: [PATCH 122/160] fix(ci): pass release tag to CD_production in manifest mode release-please cut v1.1.1 (tag + published release) but CD (Production) never deployed. The deploy-production job invoked the reusable CD_production workflow (release_created was true), but CD_production's job gate `startsWith(inputs.tag_name, 'v')` skipped it because inputs.tag_name arrived empty. Root cause: release-please-action@v5 in manifest mode exposes the unprefixed `release_created` boolean but only a path-scoped `.--tag_name` output; the unprefixed `tag_name` comes through empty. The job output mapping read the empty unprefixed value. Fall back to the path-scoped `.--tag_name` (and `.--release_created` for symmetry) so the real tag reaches CD_production and the deploy gate passes. Without this, every manifest-mode release silently skips the production deploy. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release-please.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 9269a3b1a..0aadf7d5d 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -15,8 +15,13 @@ jobs: release-please: runs-on: ubuntu-latest outputs: - release_created: ${{ steps.release.outputs.release_created }} - tag_name: ${{ steps.release.outputs.tag_name }} + # In manifest mode, release-please-action exposes the unprefixed + # `release_created` boolean but only a PATH-SCOPED `.--tag_name` (the + # unprefixed `tag_name` comes through empty). Fall back to the path-scoped + # key so the tag reaches CD_production; without it the deploy job's + # `startsWith(inputs.tag_name, 'v')` gate silently skips the release. + release_created: ${{ steps.release.outputs.release_created || steps.release.outputs['.--release_created'] }} + tag_name: ${{ steps.release.outputs.tag_name || steps.release.outputs['.--tag_name'] }} steps: # staging uses its own config/manifest pair: prerelease (rc) versioning, # separate changelog, and its own version state so the rc line never From 3153a760d32b1ae1b42eb756745201c97eba776a Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 6 Jul 2026 11:04:16 -0600 Subject: [PATCH 123/160] fix(ci): resolve release tag from manifest so CD_production deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-please-action@v5 does not populate a usable `tag_name` output in this manifest setup — it emits `release_created` plus an EMPTY `tag_name` and no path-scoped outputs. The empty tag made CD_production's `startsWith(inputs.tag_name, 'v')` gate silently skip the production deploy (observed on v1.1.1 and v1.1.2, which had to be shipped by re-publishing the release manually). It also handed the forward-merge job an empty tag. Resolve the tag ourselves from the released version in the branch-appropriate manifest (`v`, include-v-in-tag: true), preferring the action's own output if it is ever non-empty. Supersedes PR #756's `.--tag_name` fallback, which targeted a path-scoped output key this configuration never emits. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release-please.yml | 37 ++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 0aadf7d5d..bac76bf49 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -15,13 +15,8 @@ jobs: release-please: runs-on: ubuntu-latest outputs: - # In manifest mode, release-please-action exposes the unprefixed - # `release_created` boolean but only a PATH-SCOPED `.--tag_name` (the - # unprefixed `tag_name` comes through empty). Fall back to the path-scoped - # key so the tag reaches CD_production; without it the deploy job's - # `startsWith(inputs.tag_name, 'v')` gate silently skips the release. - release_created: ${{ steps.release.outputs.release_created || steps.release.outputs['.--release_created'] }} - tag_name: ${{ steps.release.outputs.tag_name || steps.release.outputs['.--tag_name'] }} + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.resolve_tag.outputs.tag_name }} steps: # staging uses its own config/manifest pair: prerelease (rc) versioning, # separate changelog, and its own version state so the rc line never @@ -33,6 +28,34 @@ jobs: manifest-file: ${{ github.ref_name == 'staging' && '.release-please-manifest.staging.json' || '.release-please-manifest.json' }} target-branch: ${{ github.ref_name }} + # release-please-action@v5 does not populate a usable `tag_name` output in + # this manifest setup: it emits `release_created` plus an EMPTY `tag_name`, + # and no path-scoped (`.--tag_name`) outputs exist. An empty tag makes + # CD_production's `startsWith(inputs.tag_name, 'v')` gate silently skip the + # deploy (and forward-merge get an empty tag). Resolve the tag ourselves + # from the released version in the branch-appropriate manifest. Both + # 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 + - id: resolve_tag + if: ${{ steps.release.outputs.release_created == 'true' }} + env: + ACTION_TAG: ${{ steps.release.outputs.tag_name }} + MANIFEST: ${{ github.ref_name == 'staging' && '.release-please-manifest.staging.json' || '.release-please-manifest.json' }} + run: | + tag="$ACTION_TAG" + if [ -z "$tag" ]; then + version="$(jq -r '.["."]' "$MANIFEST")" + if [ -z "$version" ] || [ "$version" = "null" ]; then + echo "Could not resolve released version from $MANIFEST" >&2 + exit 1 + fi + tag="v${version}" + fi + echo "Resolved release tag: $tag" + echo "tag_name=${tag}" >> "$GITHUB_OUTPUT" + # When release-please cuts a stable or hotfix release, deploy it. RC releases # on staging never deploy production. The release is created with # GITHUB_TOKEN, whose events don't trigger other workflows, so we invoke the From 5bbc53dc9710c1a4857533a8affdc432b9ecf3f1 Mon Sep 17 00:00:00 2001 From: jross Date: Mon, 6 Jul 2026 12:20:40 -0600 Subject: [PATCH 124/160] docs: describe App Engine request-starvation outage and scaling fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the 2026-07-06 site-wide 500/503 outage: blank-instanceId requests dying in the pending queue, root cause (no max_concurrent_requests vs 4 gunicorn workers → scheduler pins bursts to one instance instead of scaling out), the three-part config fix (max_concurrent_requests: 6, MIN_INSTANCES=1, -w 8), verification, and tuning notes. Co-Authored-By: Claude Opus 4.8 --- docs/app-engine-request-starvation.md | 120 ++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/app-engine-request-starvation.md diff --git a/docs/app-engine-request-starvation.md b/docs/app-engine-request-starvation.md new file mode 100644 index 000000000..48bf42911 --- /dev/null +++ b/docs/app-engine-request-starvation.md @@ -0,0 +1,120 @@ +# App Engine Request Starvation — Outage and Fix + +## Summary + +On 2026-07-06 the production App Engine service `ocotillo-api` returned +site-wide `500`/`503` errors for **every** endpoint, including `/` and +`/health`. The application itself was healthy — requests were being killed in +App Engine's pending queue before they ever reached the app, because the +scheduler was not scaling out under burst load. The fix caps per-instance +concurrency so App Engine scales out sooner, keeps one instance warm, and +raises per-instance worker count. + +## Symptoms + +- `500`/`503` on all routes, including trivial ones (`/`, `/health`). +- In Cloud Logging, the failing `RequestLog` entries had: + - status `500`/`503`, + - latency ~`0.002s`, + - a **blank `instanceId`**. + + A blank `instanceId` means App Engine never assigned the request to an + instance — it died in the pending queue, not in application code. +- Over one ~54-minute window: **279 of 300** requests were `500` with a blank + `instanceId`. The **21** requests that did reach an instance all returned + `200` in ~0.1s. +- Only **one** instance was ever serving, despite `max_instances: 10`. +- No `Exceeded soft memory limit` lines (not OOM) and no application + tracebacks on the failing requests (not a code crash). + +## Root cause + +The service runs under Gunicorn with Uvicorn workers: + +``` +gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app +``` + +Real per-instance concurrency is therefore **4** (four worker processes). + +App Engine's `automatic_scaling` block did **not** set +`max_concurrent_requests`. Without it, the scheduler assumes an instance can +absorb far more concurrent requests than the four workers can actually serve. +Under a burst — the map UI fires many +`?f=json&limit=10000` collection requests at once, each a multi-second, +10,000-feature GeoJSON serialization — the scheduler kept routing to the one +saturated instance instead of spinning up more (toward the max of 10). + +The sequence: + +1. `min_instances: 0` let the service scale to zero when idle, so the next hit + paid a cold start (observed: a 16.8s request plus "new process started"). +2. A burst arrived. All four workers on the single instance were busy. +3. App Engine, unaware that concurrency was already exhausted, kept queuing + requests to that instance rather than scaling out. +4. Queued requests exceeded the pending deadline and were aborted → + `500`/`503` for everything, including `/`. +5. The instance cycled and the pattern repeated. + +This was a **scale-out failure**, not resource exhaustion or an application +bug. The one healthy instance served every request it actually received. + +## Fix + +Three changes, all deployment configuration (no application code): + +### 1. Cap per-instance concurrency (`.github/app.template.yaml`) + +```yaml +automatic_scaling: + min_instances: ${MIN_INSTANCES} + max_instances: ${MAX_INSTANCES} + max_concurrent_requests: 6 +``` + +`max_concurrent_requests: 6` gives the scheduler an explicit ceiling, so it +scales out **before** an instance saturates. It is set intentionally *below* +the worker count (see change 3) so that ~2 workers of headroom absorb short +bursts while additional instances spin up, rather than packing an instance to +its limit before reacting. + +### 2. Keep one instance warm (`.github/workflows/CD_production.yml`) + +``` +MIN_INSTANCES = 1 # was 0 +``` + +A minimum of one always-on instance eliminates the cold-start pile-ups that +compounded the outage. + +### 3. More concurrency per instance (`.github/workflows/CD_production.yml`) + +``` +gunicorn -w 8 -k uvicorn.workers.UvicornWorker main:app # was -w 4 +``` + +The F4 instance class (1 GB) comfortably runs eight workers, doubling the +throughput of each instance. + +## Verification + +After deploying, the live App Engine version reported: + +- `maxConcurrentRequests: 6` +- `standardSchedulerSettings: { minInstances: 1, maxInstances: 10 }` + +and request logs showed only `200`/`307` responses with **zero** blank-`instanceId` +`500`s (down from 279/300 in the failing window). + +## Tuning notes + +- `max_concurrent_requests` (6) is deliberately below the Gunicorn worker + count (8). Raising it toward 8 packs each instance denser before scaling out + (cheaper, less headroom); lowering it scales out more aggressively (more + headroom, more instances). Keep it at or below the worker count — setting it + above means requests queue on a busy instance instead of triggering + scale-out, which is the exact failure this fixes. +- If heavy endpoints (large `limit` GeoJSON serializations) remain a load + concern, consider capping `max_items` on those collections or paginating, + independent of the scaling settings above. +``` From 642a285c481161b8155d5f19154c26af71c46e58 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 6 Jul 2026 15:58:38 -0500 Subject: [PATCH 125/160] feat(api/search): add groups/projects to search --- api/search.py | 61 +++++++++++++++++++++++++++++++++++++++++--- tests/test_search.py | 27 ++++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/api/search.py b/api/search.py index e3865d06e..daaa1c884 100644 --- a/api/search.py +++ b/api/search.py @@ -16,11 +16,12 @@ from fastapi import APIRouter from fastapi_pagination import paginate from fastapi_pagination.utils import disable_installed_extensions_check -from sqlalchemy import select, func, text +from sqlalchemy import select, func, text, or_ from sqlalchemy.orm import Session, selectinload from api.pagination import CustomPage from core.dependencies import session_dependency, viewer_dependency +from services.group_helper import get_well_counts_by_group_id from db import ( Contact, Email, @@ -32,6 +33,8 @@ WellPurpose, Asset, AssetThingAssociation, + Group, + GroupThingAssociation, search, ) @@ -86,10 +89,13 @@ def _get_contact_results(session: Session, q: str, limit: int) -> list[dict]: def _get_thing_results(session: Session, q: str, limit: int) -> list[dict]: + empty = text("''::tsvector") + casing_vector = func.coalesce(WellCasingMaterial.search_vector, empty) + purpose_vector = func.coalesce(WellPurpose.search_vector, empty) well_vector = ( - func.coalesce(Thing.search_vector, text("''::tsvector")) - .op("||")(func.coalesce(WellCasingMaterial.search_vector, text("''::tsvector"))) - .op("||")(func.coalesce(WellPurpose.search_vector, text("''::tsvector"))) + func.coalesce(Thing.search_vector, empty) + .op("||")(casing_vector) + .op("||")(purpose_vector) ) water_well_query = search( @@ -196,6 +202,52 @@ def _get_asset_results(session: Session, q: str, limit: int) -> list[dict]: return results +def _get_project_results(session: Session, q: str, limit: int) -> list[dict]: + search_term = f"%{q.strip()}%" + query = ( + select(Group) + .where( + or_( + Group.name.ilike(search_term), + Group.description.ilike(search_term), + Group.group_type.ilike(search_term), + ) + ) + .order_by(Group.name) + .limit(limit) + .options( + selectinload(Group.thing_associations).selectinload( + GroupThingAssociation.thing + ) + ) + ) + + projects = session.scalars(query).all() + well_counts = get_well_counts_by_group_id( + session, [project.id for project in projects] + ) + results = [ + { + "label": project.name, + "group": "Projects", + "properties": { + "id": project.id, + "description": project.description, + "group_type": project.group_type, + "parent_group_id": project.parent_group_id, + "well_count": well_counts.get(project.id, 0), + "things": [ + {"label": t.name, "id": t.id, "thing_type": t.thing_type} + for t in project.things + ], + }, + } + for project in projects + ] + + return results + + @router.get("") def search_api( user: viewer_dependency, @@ -211,6 +263,7 @@ def search_api( results = _get_contact_results(session, q, limit) results.extend(_get_thing_results(session, q, limit)) results.extend(_get_asset_results(session, q, limit)) + results.extend(_get_project_results(session, q, limit)) return paginate(results) # return {"items": results, "total": len(results)} diff --git a/tests/test_search.py b/tests/test_search.py index 42d473cae..358013cbb 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -79,6 +79,33 @@ def test_search_api( ] +def test_search_api_projects(group, water_well_thing): + response = client.get("/search", params={"q": "Test Group"}) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, dict) + items = data.get("items") + assert isinstance(items, list) + + project_items = [item for item in items if item["group"] == "Projects"] + assert len(project_items) == 1 + project_item = project_items[0] + assert project_item["label"] == group.name + assert project_item["properties"]["id"] == group.id + assert project_item["properties"]["description"] == group.description + assert project_item["properties"]["group_type"] == group.group_type + parent_group_id = project_item["properties"]["parent_group_id"] + assert parent_group_id == group.parent_group_id + assert project_item["properties"]["well_count"] == 1 + assert project_item["properties"]["things"] == [ + { + "label": water_well_thing.name, + "id": water_well_thing.id, + "thing_type": water_well_thing.thing_type, + }, + ] + + @pytest.mark.skip(reason="This test is not working .") def test_search_api2(): response = client.get("/search", params={"q": "riochama"}) From bdadeaffea5816ae643d92ebc04a8d76ee79519a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 6 Jul 2026 16:27:57 -0500 Subject: [PATCH 126/160] fix(api/search): update group db schema to have a vector search --- .../y3z4a5b6c7d8_add_group_search_vector.py | 66 +++++++++++++++++++ api/search.py | 22 ++----- db/group.py | 4 ++ 3 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 alembic/versions/y3z4a5b6c7d8_add_group_search_vector.py diff --git a/alembic/versions/y3z4a5b6c7d8_add_group_search_vector.py b/alembic/versions/y3z4a5b6c7d8_add_group_search_vector.py new file mode 100644 index 000000000..5e39813d2 --- /dev/null +++ b/alembic/versions/y3z4a5b6c7d8_add_group_search_vector.py @@ -0,0 +1,66 @@ +"""add group search vector + +Revision ID: y3z4a5b6c7d8 +Revises: e2f3a4b5c6d7 +Create Date: 2026-07-06 16:20:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +import sqlalchemy_utils +from alembic import op + + +revision: str = "y3z4a5b6c7d8" +down_revision: Union[str, None] = "e2f3a4b5c6d7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "group", + sa.Column( + "search_vector", + sqlalchemy_utils.types.ts_vector.TSVectorType(), + nullable=True, + ), + ) + op.create_index( + "ix_group_search_vector", + "group", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.execute( + """ + UPDATE "group" + SET search_vector = to_tsvector( + 'pg_catalog.simple', + concat_ws(' ', name, description, group_type) + ) + """ + ) + op.execute( + """ + CREATE TRIGGER "group_search_vector_update" + BEFORE INSERT OR UPDATE ON "group" + FOR EACH ROW EXECUTE PROCEDURE + tsvector_update_trigger( + 'search_vector', + 'pg_catalog.simple', + 'name', + 'description', + 'group_type' + ) + """ + ) + + +def downgrade() -> None: + op.execute('DROP TRIGGER IF EXISTS "group_search_vector_update" ON "group"') + op.drop_index("ix_group_search_vector", table_name="group", postgresql_using="gin") + op.drop_column("group", "search_vector") diff --git a/api/search.py b/api/search.py index daaa1c884..34748ea38 100644 --- a/api/search.py +++ b/api/search.py @@ -16,7 +16,7 @@ from fastapi import APIRouter from fastapi_pagination import paginate from fastapi_pagination.utils import disable_installed_extensions_check -from sqlalchemy import select, func, text, or_ +from sqlalchemy import select, func, text from sqlalchemy.orm import Session, selectinload from api.pagination import CustomPage @@ -203,23 +203,15 @@ def _get_asset_results(session: Session, q: str, limit: int) -> list[dict]: def _get_project_results(session: Session, q: str, limit: int) -> list[dict]: - search_term = f"%{q.strip()}%" - query = ( - select(Group) - .where( - or_( - Group.name.ilike(search_term), - Group.description.ilike(search_term), - Group.group_type.ilike(search_term), - ) - ) - .order_by(Group.name) - .limit(limit) - .options( + query = search( + select(Group).options( selectinload(Group.thing_associations).selectinload( GroupThingAssociation.thing ) - ) + ), + q, + vector=Group.search_vector, + limit=limit, ) projects = session.scalars(query).all() diff --git a/db/group.py b/db/group.py index 9445ca07a..14994fbc3 100644 --- a/db/group.py +++ b/db/group.py @@ -19,6 +19,7 @@ from sqlalchemy import String, Integer, ForeignKey, UniqueConstraint from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from sqlalchemy.orm import relationship, Mapped, mapped_column +from sqlalchemy_utils import TSVectorType from core.constants import SRID_WGS84 from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term @@ -36,6 +37,9 @@ class Group(Base, AutoBaseMixin, ReleaseMixin): Geometry(geometry_type="MULTIPOLYGON", srid=SRID_WGS84, spatial_index=True) ) group_type: Mapped[Optional[str]] = lexicon_term(nullable=True) + search_vector: Mapped[TSVectorType] = mapped_column( + TSVectorType("name", "description", "group_type") + ) # Foreign Keys parent_group_id: Mapped[Optional[int]] = mapped_column( From ae897303af80d33f1aff516c0cb9c3801d943c2b Mon Sep 17 00:00:00 2001 From: TylerAdamMartinez <57375362+TylerAdamMartinez@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:48:20 +0000 Subject: [PATCH 127/160] Formatting changes --- .../y3z4a5b6c7d8_add_group_search_vector.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/alembic/versions/y3z4a5b6c7d8_add_group_search_vector.py b/alembic/versions/y3z4a5b6c7d8_add_group_search_vector.py index 5e39813d2..83ba6f81d 100644 --- a/alembic/versions/y3z4a5b6c7d8_add_group_search_vector.py +++ b/alembic/versions/y3z4a5b6c7d8_add_group_search_vector.py @@ -12,7 +12,6 @@ import sqlalchemy_utils from alembic import op - revision: str = "y3z4a5b6c7d8" down_revision: Union[str, None] = "e2f3a4b5c6d7" branch_labels: Union[str, Sequence[str], None] = None @@ -35,17 +34,14 @@ def upgrade() -> None: unique=False, postgresql_using="gin", ) - op.execute( - """ + op.execute(""" UPDATE "group" SET search_vector = to_tsvector( 'pg_catalog.simple', concat_ws(' ', name, description, group_type) ) - """ - ) - op.execute( - """ + """) + op.execute(""" CREATE TRIGGER "group_search_vector_update" BEFORE INSERT OR UPDATE ON "group" FOR EACH ROW EXECUTE PROCEDURE @@ -56,8 +52,7 @@ def upgrade() -> None: 'description', 'group_type' ) - """ - ) + """) def downgrade() -> None: From bb740c221f15b5b64121cf95cfe3aba061b6fd8e Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 6 Jul 2026 16:33:46 -0600 Subject: [PATCH 128/160] fix(ci): backport CD (Production) deploy-gate fix in hotfix-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hotfix branches are cut from release tags via `git checkout -b `, so they inherit whatever CD_production.yml existed at that tag. Tags cut before the deploy-gate fix carry a `production-deploy` gate keyed on `github.event_name == 'workflow_call'` — never true inside a called workflow, which inherits the caller's `push` event — so release-please's inline deploy skips and the hotfix never ships. After creating the branch, surgically rewrite the gate to key on `inputs.tag_name` (empty on the release-event path, so `||` falls through). Surgical sed only, not a whole-file copy from the default branch, whose deploy steps may not match the tag's code (e.g. the renamed refresh-materialized-views CLI, pg_cron, Secret Manager fetch). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/hotfix-start.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/hotfix-start.yml b/.github/workflows/hotfix-start.yml index 4489ce61c..6f1a81e4c 100644 --- a/.github/workflows/hotfix-start.yml +++ b/.github/workflows/hotfix-start.yml @@ -72,6 +72,26 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout -b "${{ steps.next.outputs.branch }}" "${{ steps.base.outputs.tag }}" + + # Backport the CD (Production) inline-deploy gate fix. Tags cut before + # the fix carry a gate keyed on `github.event_name == 'workflow_call'`, + # which is NEVER true inside a called workflow (it inherits the + # caller's `push` event), so release-please's inline deploy silently + # skips `production-deploy` and the hotfix never ships. Rewrite the + # gate to key on `inputs.tag_name` (empty on the release path, so `||` + # still falls through). SURGICAL edit only — do not copy the whole + # file from the default branch, whose deploy steps may not match this + # tag's code. + f=.github/workflows/CD_production.yml + if [ -f "$f" ] && grep -qF "github.event_name == 'workflow_call'" "$f"; then + sed -i "s#(github.event_name == 'workflow_call' && inputs.tag_name) || github.event.release.tag_name#inputs.tag_name || github.event.release.tag_name#g" "$f" + git add "$f" + git commit -m "ci: backport CD (Production) inline-deploy gate fix" + echo "Backported CD (Production) deploy-gate fix onto ${{ steps.next.outputs.branch }}." + else + echo "CD (Production) deploy gate already current; no backport needed." + fi + git push origin "${{ steps.next.outputs.branch }}" - name: Summary From a830e714171bb64d52929b6efbe0b685ac87e72c Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 7 Jul 2026 14:31:03 -0600 Subject: [PATCH 129/160] fix(deploy): propagate F4_1G instance class to staging + document OOM churn The F4 -> F4_1G instance-class bump shipped to production in v1.1.5 (branch hotfix/v1.1.5) but the shared app.template.yaml on staging still read F4, so the next staging -> production release would have regressed prod back to F4 and reintroduced the OOM instance churn. Propagate the same class here. Also add docs/app-engine-oom-instance-churn.md describing the degradation (gunicorn -w 8 OOMing the 1 GB F4, ~44 over-memory terminations / 3h, continuous worker cycling) and the resolution, as a companion to app-engine-request-starvation.md. Co-Authored-By: Claude Opus 4.8 --- .github/app.template.yaml | 8 +- docs/app-engine-oom-instance-churn.md | 153 ++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 docs/app-engine-oom-instance-churn.md diff --git a/.github/app.template.yaml b/.github/app.template.yaml index f2b4e92bc..6a1a52fb4 100644 --- a/.github/app.template.yaml +++ b/.github/app.template.yaml @@ -2,7 +2,13 @@ service: ${SERVICE_NAME} runtime: python313 entrypoint: ${ENTRYPOINT} service_account: "${CLOUD_SQL_USER}.gserviceaccount.com" -instance_class: F4 +# F4_1G (2 GB) not F4 (1 GB): production runs gunicorn -w 8, and eight workers +# each importing the full stack (sqlalchemy + geoalchemy2 + shapely + cloud-sql +# connector + pygeoapi) exceeded 1 GB, so App Engine terminated processes for +# "using too much memory" and cycled workers continuously, cold-loading every +# request. See docs/app-engine-oom-instance-churn.md. Shipped to prod in v1.1.5; +# this propagates the same class to staging/testing to prevent regression. +instance_class: F4_1G inbound_services: - warmup automatic_scaling: diff --git a/docs/app-engine-oom-instance-churn.md b/docs/app-engine-oom-instance-churn.md new file mode 100644 index 000000000..8d6c99123 --- /dev/null +++ b/docs/app-engine-oom-instance-churn.md @@ -0,0 +1,153 @@ +# App Engine OOM Instance Churn — Degradation and Fix + +## Summary + +On 2026-07-07 the production App Engine service `ocotillo-api` served **every** +request slowly, even at low traffic. The application was not returning errors — +requests eventually succeeded — but latency was uniformly high because the +single serving instance was being killed and restarted continuously. Each +request therefore tended to land on a process that was still cold-loading the +application. + +The cause was memory exhaustion introduced by the previous scaling fix (see +[`app-engine-request-starvation.md`](app-engine-request-starvation.md)), which +raised the Gunicorn worker count from 4 to 8. Eight workers did not fit in the +F4 (1 GB) instance class. The fix raises the instance class to F4_1G (2 GB). + +This is the direct sequel to the request-starvation outage: that fix cured the +scale-out failure but overshot on per-instance worker count for the instance's +memory. + +## Symptoms + +- Uniformly slow responses on all routes, **independent of traffic volume** — + slow even when only one or two users were active. +- Not an outage: requests returned `200`, just slowly. No site-wide `500`/`503` + (distinguishing this from the earlier starvation outage). +- Presented as "the API cold-starts for every user." + +## Investigation + +Because `min_instances` was `1`, one instance should always have been warm, so +uniform slowness at low traffic pointed at either (a) the warm instance +recycling, or (b) genuinely slow per-request work. + +Per-request work was ruled out by reading `db/engine.py`: the SQLAlchemy engine +and connection pool are created once at module import +(`engine = init_connection_pool(connector)`) and reused across requests +(`pool_pre_ping=True`, `echo=False`). Requests do not re-establish connections, +so the slowness was not per-request connection setup. + +That left instance recycling. Cloud Logging confirmed it. An initial query for +`"Exceeded soft memory limit"` returned nothing — that is the wrong phrase for +the App Engine **standard** environment. The correct signal is the message +below. + +### Evidence (production, 3-hour window) + +- **44** requests logged: *"the process that handled this request was found to + be using too much memory and was terminated ... Consider setting a larger + instance class in app.yaml."* +- **304** `Booting worker` lines — Gunicorn workers restarting continuously. +- **297** `SIGTERM` / **297** `was sent` — App Engine killing over-memory + processes. +- **334** `shutting down` — instance/worker shutdown churn. +- Multiple *"This request caused a new process to be started ... loaded for the + first time"* lines — cold loading requests hitting users directly. +- Live serving version confirmed as instance class **F4**, one instance, + 100% traffic. + +A worker was being killed for memory roughly every four minutes, so the +"always-on" instance was effectively always cold. + +## Root cause + +The production entrypoint runs: + +``` +gunicorn -w 8 -k uvicorn.workers.UvicornWorker main:app +``` + +on instance class **F4 (1 GB)**. Each of the eight worker processes imports the +full application stack independently — SQLAlchemy, GeoAlchemy2, Shapely, the +Cloud SQL Python connector, and pygeoapi — with no `--preload`, so there is no +copy-on-write sharing between workers. Eight independent copies of that stack +exceeded 1 GB, so App Engine terminated worker processes for using too much +memory and started new ones, indefinitely. + +The prior request-starvation fix deliberately raised `-w 4` → `-w 8` for more +per-instance throughput. On its own that change was reasonable, but it was not +paired with more memory, and 8 workers do not fit in F4. + +## Fix + +One change, deployment configuration only (`.github/app.template.yaml`): + +```yaml +instance_class: F4_1G # was F4 +``` + +F4_1G provides 2 GB (same 2.4 GHz class), which comfortably holds eight workers +and their imports. This follows App Engine's own remediation message +("Consider setting a larger instance class"). + +The change deliberately preserves the scale-out tuning from the starvation fix +— `gunicorn -w 8`, `max_concurrent_requests: 6`, `min_instances: 1`, +`max_instances: 10` — so the earlier `500`/`503` starvation cannot recur. + +### Alternatives considered + +- **Reduce workers (`-w 8` → `-w 4`).** Would cut memory ~half at no cost, but + requires also lowering `max_concurrent_requests` to stay at or below the + worker count; otherwise the request-starvation failure returns. Retunes the + balance the prior fix set. Rejected in favor of the lower-risk memory bump. +- **Add `--preload`.** Would share imports across forked workers, but the + module-level SQLAlchemy engine / Cloud SQL `Connector` is created at import, + and sharing a pool across forked processes is unsafe without a post-fork + `engine.dispose()`. Not a one-line hotfix; rejected. + +### Shared template note + +`app.template.yaml` renders for all environments. Staging and testing run +`-w 4` and scale to zero (`MIN_INSTANCES=0`), so raising their class to F4_1G +grants harmless headroom at negligible idle cost. If per-environment instance +classes become desirable, template `instance_class` as `${INSTANCE_CLASS}` and +export it from each `CD_*` workflow, mirroring `MIN_INSTANCES` / `ENTRYPOINT`. + +## Verification + +After deploying `v1.1.5`, confirm on the live version: + +```bash +gcloud app versions list --service=ocotillo-api \ + --project=waterdatainitiative-271000 \ + --hide-no-traffic --format='value(id, version.instanceClass)' +# expect: F4_1G +``` + +and confirm the over-memory terminations stop: + +```bash +gcloud logging read \ + 'resource.type="gae_app" AND resource.labels.module_id="ocotillo-api" + AND "using too much memory"' \ + --project=waterdatainitiative-271000 --freshness=1h --format='value(timestamp)' +# expect: no results after the new version takes traffic +``` + +Request latency should drop to the warm-path baseline (~0.1 s for light +endpoints) once workers stop cycling. + +## Tuning notes + +- The correct App Engine **standard** memory-kill phrase for log searches is + *"using too much memory and was terminated"*, **not** *"Exceeded soft memory + limit"* (which is flex-environment wording). Searching the wrong phrase + returns a false all-clear. +- Keep `max_concurrent_requests` (6) at or below the Gunicorn worker count (8); + see the starvation doc for why. +- If memory again becomes tight after future dependency growth, the next levers + are `--preload` with a post-fork pool dispose, or reducing worker count with a + matching `max_concurrent_requests` reduction — before jumping another instance + class. +``` From a0d0f84013891bccd3c42703619c4d03c3311c6d Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 7 Jul 2026 14:32:52 -0600 Subject: [PATCH 130/160] chore: stop ignoring docs/ so markdown docs track normally docs/ was gitignored, so every hand-written doc had to be force-added (git add -f). The directory holds tracked reference docs, not generated output, so remove the ignore and let new docs commit without -f. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3c93f2834..b001e6f5e 100644 --- a/.gitignore +++ b/.gitignore @@ -49,7 +49,6 @@ cli/logs .pygeoapi/ # deployment files app.yaml -docs/ #Codex .codex From 253b630b9509452edc793e6ae43c3b95fb72eb3d Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 7 Jul 2026 14:36:21 -0600 Subject: [PATCH 131/160] docs: add API monitoring options writeup Reference doc surveying production API monitoring options for OcotilloAPI. Force-added because docs/ is still gitignored on staging (the ignore removal rides a separate PR). Co-Authored-By: Claude Opus 4.8 --- docs/api-monitoring-options.md | 169 +++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/api-monitoring-options.md diff --git a/docs/api-monitoring-options.md b/docs/api-monitoring-options.md new file mode 100644 index 000000000..2dbbc611a --- /dev/null +++ b/docs/api-monitoring-options.md @@ -0,0 +1,169 @@ +# API Monitoring Options for OcotilloAPI Production + +**Purpose:** Evaluate three open-source uptime and health-check monitoring tools for watching production deployments of OcotilloAPI (FastAPI + PostgreSQL/PostGIS). + +**Focus:** Uptime and health checks — is the API reachable, is it responding within acceptable latency, and does its health endpoint report the database and dependencies as healthy. This document does not cover full APM/tracing platforms (SigNoz, Grafana Tempo, etc.), which are a heavier and separate decision. + +**Date:** 2026-07-07 + +--- + +## What OcotilloAPI needs monitored + +OcotilloAPI is a FastAPI service backed by PostgreSQL + PostGIS. A practical uptime/health monitor for this stack should cover: + +- **Reachability** of the public API (HTTP/HTTPS status codes, TLS certificate validity/expiry). +- **A health endpoint** — expose a `/health` (and optionally `/health/db`) route in FastAPI that checks database connectivity and returns JSON like `{"status": "ok", "db": "ok"}`. All three tools below can assert against that JSON. +- **Response-time thresholds** — flag slow spatial queries before users notice. +- **Alerting** to a channel the team watches (email, Slack, PagerDuty). +- **Self-hostable** alongside the existing Docker Compose stack, and ideally versioned in the repo. + +A recommended FastAPI health route to monitor: + +```python +from fastapi import APIRouter, Depends +from sqlalchemy import text + +router = APIRouter() + +@router.get("/health") +async def health(session=Depends(get_session)): + await session.execute(text("SELECT 1")) + return {"status": "ok", "db": "ok"} +``` + +--- + +## Option 1 — Uptime Kuma + +**License:** MIT · **Language:** Node.js/Vue · **Repo:** github.com/louislam/uptime-kuma (~76k+ GitHub stars — the most popular self-hosted uptime monitor) + +Uptime Kuma is a UI-driven, self-hosted uptime monitor. You add and configure monitors through a polished web dashboard rather than a config file. + +**Relevant capabilities** + +- Monitor types include HTTP/HTTPS, TCP port, ping, DNS, keyword-in-response, **HTTP(S) JSON query**, database checks, and Docker containers. +- HTTP monitor checks the status code, can assert a **keyword** or a **JSON query** against the response body (ideal for asserting `"db": "ok"` from the health route), and warns on certificate expiry within a configurable threshold. +- Built-in status pages, per-monitor history/uptime %, and a large set of notification integrations (Slack, email/SMTP, Telegram, Discord, PagerDuty, webhooks, and many more). +- v2.0 (Oct 2025) added MariaDB backend support, rootless Docker images, refreshed UI. v2.1 (Feb 2026) added Globalping worldwide probes and domain-expiry monitoring. + +**Fit for OcotilloAPI** + +Fastest path to "is the API up and is the DB healthy." Drops into the existing Docker Compose stack as one container, and the JSON-query monitor maps directly onto a FastAPI `/health` response. Best when the team wants a friendly UI and public status page with minimal setup. + +**Trade-offs** + +- Config lives in the app's own database, not in the repo — no native config-as-code/GitOps (community tools like the `uptime-kuma-api` Python package or `uptime-kuma-web-api` can script setup, but it is not first-class). +- No official REST API; automation goes through the Socket.IO API. +- Single-instance architecture; not built for horizontally-scaled HA. + +--- + +## Option 2 — Gatus + +**License:** Apache 2.0 · **Language:** Go · **Repo:** github.com/TwiN/gatus + +Gatus is a lightweight, developer-oriented health dashboard where every monitored endpoint, condition, and alert rule is declared in a **YAML file**. That makes it a natural fit for GitOps — monitoring changes become versioned commits. + +**Relevant capabilities** + +- Probes HTTP, TCP, ICMP, DNS, WebSocket, SSH, TLS, and STARTTLS endpoints on a schedule. +- Declarative **conditions** on status code, response time, response body (including JSON assertions, e.g. `[BODY].db == ok`), IP, and TLS certificate expiration. +- `failure-threshold` / `success-threshold` settings prevent alert flapping from intermittent blips. +- Alerting out of the box: Slack, Mattermost, PagerDuty, Twilio, Google Chat, Teams, Messagebird, plus custom providers. +- Built-in web dashboard with per-endpoint status, response-time history, and uptime % — no Grafana required for basic visualization. + +**Fit for OcotilloAPI** + +Strong match for a team that already versions infrastructure. A single `config.yaml` lives in the repo next to OcotilloAPI, defining checks against `/health`, asserting the JSON body and a response-time ceiling, and firing alerts after N consecutive failures. Lightweight Go binary/container, low resource use. + +Example condition set: + +```yaml +endpoints: + - name: ocotillo-api-health + url: "https://api.example.org/health" + interval: 60s + conditions: + - "[STATUS] == 200" + - "[BODY].db == ok" + - "[RESPONSE_TIME] < 500" + alerts: + - type: slack + failure-threshold: 3 + success-threshold: 2 +``` + +**Trade-offs** + +- No point-and-click UI for adding monitors — everything is YAML (a feature for engineers, friction for non-technical stakeholders). +- Status-page/incident features are lighter than Uptime Kuma's. + +--- + +## Option 3 — Prometheus Blackbox Exporter + +**License:** Apache 2.0 · **Language:** Go · **Repo:** github.com/prometheus/blackbox_exporter (official Prometheus / CNCF component) + +The Blackbox Exporter probes endpoints externally and exposes the results as **Prometheus metrics**. It is the production-grade, standards-based choice — but it is a component, not a standalone product: it assumes (or introduces) a Prometheus + Alertmanager stack, usually with Grafana for dashboards. + +**Relevant capabilities** + +- Probes over HTTP, HTTPS, DNS, TCP, ICMP, and gRPC. +- HTTP probe defaults to GET expecting 2xx; configurable for other methods, expected status codes, **basic/bearer auth**, custom headers, body matching (regex on response), and proxies. +- Emits metrics such as `probe_success`, `probe_duration_seconds`, `probe_http_status_code`, and `probe_ssl_earliest_cert_expiry` (TLS expiry timestamp). +- Alerting via Prometheus alerting rules → Alertmanager (routing, grouping, silencing, dedup) to Slack, PagerDuty, email, etc. +- Multi-target / multi-region probing and long-term metric retention when paired with the Prometheus stack. + +**Fit for OcotilloAPI** + +Best long-term fit **if** OcotilloAPI already runs, or plans to run, Prometheus for infrastructure metrics. Then endpoint uptime, latency, and cert expiry become just more series alongside app and host metrics, with unified Grafana dashboards and Alertmanager routing. Body-regex matching can assert the health-endpoint payload. + +**Trade-offs** + +- Heaviest setup by far: Blackbox Exporter + Prometheus + Alertmanager (+ Grafana) to reach parity with what Uptime Kuma or Gatus give in one container. +- No built-in status page or friendly UI on its own. +- Overkill if uptime/health is the only goal and there is no existing Prometheus footprint. + +--- + +## Comparison + +| Criterion | Uptime Kuma | Gatus | Blackbox Exporter | +|---|---|---|---| +| License | MIT | Apache 2.0 | Apache 2.0 | +| Configuration | Web UI (stored in DB) | YAML (config-as-code) | YAML + Prometheus config | +| JSON health-body assertion | Yes (JSON query) | Yes (`[BODY]` conditions) | Regex on body | +| TLS expiry checks | Yes | Yes | Yes | +| Response-time thresholds | Yes | Yes | Yes (via Prometheus rules) | +| Built-in dashboard | Yes (rich + status page) | Yes (lightweight) | No (needs Grafana) | +| Alerting | Many integrations built in | Many integrations built in | Via Alertmanager | +| Setup effort | Low (1 container) | Low (1 container) | High (full stack) | +| GitOps / versioned config | No (community tooling) | Yes (native) | Yes | +| Best when… | Want a UI + status page fast | Want config in the repo | Already run Prometheus | + +--- + +## Recommendation + +For OcotilloAPI's stated goal — uptime and health-check monitoring of a production FastAPI + PostGIS service — the pragmatic ranking: + +1. **Gatus** if the team values keeping monitoring configuration versioned in the repo alongside the code (consistent with this project's alembic/config-as-code habits). One YAML file, one container, JSON health assertions, and flap-resistant alerting. +2. **Uptime Kuma** if a friendly UI and a public/internal status page matter more than GitOps, and the team wants the quickest possible setup. +3. **Blackbox Exporter** only if (or once) OcotilloAPI adopts Prometheus for broader infrastructure metrics — then fold endpoint monitoring into that stack rather than running a separate tool. + +A reasonable starting move: add a `/health` route to FastAPI that verifies PostGIS connectivity, then stand up **Gatus** as a container in the existing Docker Compose stack with a repo-committed `config.yaml`. Revisit Blackbox Exporter if/when a Prometheus stack is introduced. + +--- + +## Sources + +- [Uptime Kuma — GitHub](https://github.com/louislam/uptime-kuma) +- [Uptime Kuma — official site](https://uptimekuma.org/) +- [Uptime Kuma: Self-Hosted Uptime Monitoring for Servers and APIs](https://trivox.sh/blog/content/uptime-kuma-self-hosted-monitoring/) +- [Gatus — GitHub](https://github.com/TwiN/gatus) +- [Gatus: A Complete Guide to Self-Hosted Service Monitoring and Status Pages](https://www.blog.brightcoding.dev/2025/07/26/gatus-a-complete-guide-to-self-hosted-service-monitoring-and-status-pages/) +- [Gatus vs Uptime Kuma: A Detailed Comparison (2026)](https://openalternative.co/compare/gatus/vs/uptime-kuma) +- [Prometheus Blackbox Exporter — GitHub](https://github.com/prometheus/blackbox_exporter) +- [Prometheus Blackbox Exporter: Ultimate Guide (SolarWinds)](https://www.solarwinds.com/blog/prometheus-blackbox-exporter) +- [How to Use Alertmanager and Blackbox Exporter to Monitor Your Web Server (DigitalOcean)](https://www.digitalocean.com/community/tutorials/how-to-use-alertmanager-and-blackbox-exporter-to-monitor-your-web-server-on-ubuntu-16-04) From 8625ebec0d4541cd1c99e86d0e618306daabd979 Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 7 Jul 2026 14:34:12 -0600 Subject: [PATCH 132/160] docs: add geoserver docs previously hidden by gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These GeoServer reference docs (state/architecture/verification from 2026-05-18) existed locally but were untracked because docs/ was gitignored. Now that the ignore is removed, commit them. No secrets — GEOSERVER_USERNAME/PASSWORD references are env-var names only. Co-Authored-By: Claude Opus 4.8 --- docs/geoserver-architecture-2026-05-18.md | 81 +++++++++++ ...erver-current-state-analysis-2026-05-18.md | 137 ++++++++++++++++++ .../geoserver-current-state-doc-2026-05-18.md | 98 +++++++++++++ ...erver-verification-checklist-2026-05-18.md | 101 +++++++++++++ 4 files changed, 417 insertions(+) create mode 100644 docs/geoserver-architecture-2026-05-18.md create mode 100644 docs/geoserver-current-state-analysis-2026-05-18.md create mode 100644 docs/geoserver-current-state-doc-2026-05-18.md create mode 100644 docs/geoserver-verification-checklist-2026-05-18.md diff --git a/docs/geoserver-architecture-2026-05-18.md b/docs/geoserver-architecture-2026-05-18.md new file mode 100644 index 000000000..259f5a691 --- /dev/null +++ b/docs/geoserver-architecture-2026-05-18.md @@ -0,0 +1,81 @@ +# GeoServer Architecture Snapshot (2026-05-18) + +## System Context +```mermaid +graph LR + subgraph Operators + OP1[AEM Ingest Operator] + OP2[Platform/Infra Maintainer] + OP3[GIS Consumer] + end + + subgraph OcotilloAPI Repo Runtime + BATCH[services/aem_batch.py] + INGEST[services/aem_asset_ingest.py] + PUB[services/geoserver_helper.py] + STAC[services/aem_stac.py] + OGC[FastAPI + mounted pygeoapi /ogcapi] + DB[(PostgreSQL + PostGIS)] + end + + subgraph GeoServer Stack + LB[HTTPS Load Balancer] + GS[GeoServer Container] + REST[GeoServer REST API] + end + + subgraph Storage + GCS[(GCS Buckets)] + MNT[Mounted filesystem path] + end + + OP1 --> BATCH + OP1 --> INGEST + + BATCH --> GCS + INGEST --> GCS + BATCH --> PUB + INGEST --> PUB + PUB --> REST + PUB --> DB + + STAC --> DB + STAC --> LB + + OP3 --> LB + OP3 --> OGC + + GCS --> MNT + MNT --> GS + LB --> GS + GS --> DB + + OP2 --> LB + OP2 --> GS +``` + +## Main Runtime Flows + +### 1. GeoTIFF publish flow +1. AEM ingest uploads GeoTIFF to GCS. +2. AEM ingest/batch invokes GeoServer publisher helper. +3. Publisher resolves mounted source path and calls GeoServer REST. +4. Publisher updates asset publish tracking fields in DB. + +### 2. STAC service-link flow +1. STAC collection payload is built. +2. If GeoServer env vars are set, collection assets include WMS/WFS/WCS links. +3. GIS clients discover GeoServer endpoints via those links. + +### 3. Current hybrid serving flow +1. OGC API Features remains on mounted pygeoapi under /ogcapi. +2. GeoServer handles AEM raster publication and related OWS exposure. + +## Deployment Topology (Defined in IaC) +- GCP VM + instance group + HTTPS load balancer. +- Startup script installs docker and gcsfuse, mounts bucket paths, runs GeoServer container. +- Domain front door configured for https://geoserver.newmexicowaterdata.org/geoserver. + +## Boundary Clarification +- OcotilloAPI remains the primary app/API process for core REST and pygeoapi-backed OGC Features. +- GeoServer is currently a specialized publishing tier for AEM-oriented map/coverage/feature service surfaces. diff --git a/docs/geoserver-current-state-analysis-2026-05-18.md b/docs/geoserver-current-state-analysis-2026-05-18.md new file mode 100644 index 000000000..d699ff770 --- /dev/null +++ b/docs/geoserver-current-state-analysis-2026-05-18.md @@ -0,0 +1,137 @@ +# GeoServer Current State Analysis (2026-05-18) + +## Scope +This is a current-state snapshot of GeoServer-related implementation and operations in this repository. All work described here lives on the `geophysical/poc` branch. + +## Companion Doc +- Architecture diagram: docs/geoserver-architecture-2026-05-18.md + +## 1) Current Purpose of GeoServer +GeoServer currently appears to serve as a dedicated geospatial publishing tier for AEM raster and service-link use cases, while the main Ocotillo OGC feature API is still served by mounted pygeoapi under /ogcapi. + +Evidence: +- ADR states pygeoapi remains active for /ogcapi and GeoServer is the strategic direction for public geospatial delivery (ADR is still marked Proposed and partially superseded). +- AEM ingest/batch code publishes validated GeoTIFF assets to GeoServer via REST (workspace + coverage store registration). +- STAC collection builders can emit GeoServer WMS/WFS/WCS links when GeoServer env vars are configured. + +Net: GeoServer is in active support for AEM publishing workflows, but it is not yet the sole or primary geospatial interface in app runtime because pygeoapi still serves the OGC API feature surface. + +## 2) Components That Exist Today + +### Application-side components +- GeoServer publisher helper: + - services/geoserver_helper.py + - Handles: + - config via GEOSERVER_URL, GEOSERVER_USERNAME, GEOSERVER_PASSWORD + - idempotent workspace/store handling + - external GeoTIFF registration using GeoServer REST endpoint + - publish status tracking payloads +- AEM asset ingest integration: + - services/aem_asset_ingest.py + - Invokes publish_geotiff_asset for validated AEM GeoTIFFs. +- AEM batch orchestration: + - services/aem_batch.py + - Routes geotiff records through upload + GeoServer publish attempt. +- STAC asset link generation: + - services/aem_stac.py + - Adds optional survey-level WMS/WFS/WCS links when GEOSERVER_PUBLIC_URL and GEOSERVER_WORKSPACE are set. + +### Data model and migration support +- Asset publish tracking fields exist in DB model: + - db/asset.py + - publish_target, publish_status, publish_workspace, publish_store_name, publish_layer_name, publish_last_attempt_at, publish_last_error +- Migration that added those fields: + - alembic/versions/u1v2w3x4y5z6_add_asset_publish_tracking_columns.py + +### Testing coverage +- GeoServer publisher unit tests: + - tests/test_geoserver_helper.py + - Covers: create missing workspace/store, idempotency, failure recording, missing source root error handling +- AEM ingest tests verifying publish state persistence: + - tests/test_aem_asset_ingest.py +- STAC tests for GeoServer collection assets: + - tests/test_aem.py (GeoServer WMS/WFS/WCS asset expectations) + +### Infrastructure / deployment components +- Terraform module for standalone GeoServer stack: + - geoserver_iac/main.tf + - geoserver_iac/variables.tf + - geoserver_iac/outputs.tf + - geoserver_iac/versions.tf + - geoserver_iac/startup-geoserver.sh.tpl +- Provisioning pattern: + - GCP VM instance + instance group + - HTTPS load balancer with managed certificate and health checks + - Startup script installs docker + gcsfuse, mounts GCS bucket(s), runs GeoServer container +- Container image pin: + - docker.osgeo.org/geoserver:2.28.0 + +## 3) Rough Existing Data Flows + +### Flow A: AEM GeoTIFF ingest -> GeoServer publish +1. AEM batch/run ingests file metadata and uploads GeoTIFF to GCS. +2. Asset metadata record is created or updated. +3. GeoServer helper resolves mounted filesystem source path from GEOSERVER_RASTER_SOURCE_ROOT + storage path. +4. GeoServer helper ensures workspace exists. +5. GeoServer helper checks for existing coverage store; if absent, registers external GeoTIFF. +6. Publish result is persisted to asset publish_* tracking columns. + +### Flow B: STAC collection generation -> GeoServer service links +1. AEM STAC collection is built. +2. If GEOSERVER_PUBLIC_URL and GEOSERVER_WORKSPACE are set, collection assets include: + - WMS GetCapabilities + - WFS GetFeature + - WCS DescribeCoverage +3. Resulting links point clients to GeoServer OWS endpoints. + +### Flow C: Parallel geospatial serving mode (current) +1. Main API still serves OGC API Features under /ogcapi using pygeoapi. +2. GeoServer is used for AEM GeoTIFF publication and optional STAC map/feature/coverage links. +3. This implies a hybrid platform state (pygeoapi + GeoServer). + +## 4) Known Basic Use Cases and Users + +### Supported use cases (known) +- Publish validated AEM GeoTIFFs to GeoServer during batch ingest. +- Track publication outcomes per asset (success/failed/skipped/disabled). +- Provide GeoServer WMS/WFS/WCS links in STAC collections for AEM datasets. +- Serve GeoServer through public HTTPS endpoint (domain configured in Terraform vars). + +### Likely user groups (inferred from code/docs) +- Data engineering / ingestion operators running AEM batch and single-file ingest. +- GIS/data consumers using WMS/WFS/WCS endpoints linked from STAC artifacts. +- Platform/infra maintainers operating GCP/Terraform deployment for GeoServer. + +### Not shown as implemented in repo +- Full replacement of /ogcapi feature APIs by GeoServer. +- GeoServer workspace/layer/style lifecycle as code (beyond runtime REST publication in ingest path). +- End-user UI workflows in Ocotillo admin for GeoServer management. + +## 5) How It Is Deployed (As Implemented) +- Infra-as-code in geoserver_iac deploys a dedicated GCP VM-based stack. +- VM startup script: + - installs docker and gcsfuse + - mounts configured GCS bucket prefixes (GeoServer data and optional surveys) + - runs GeoServer container on 8080 with proxy base URL set to https:///geoserver +- HTTPS load balancer fronts VM instance group and health-checks /geoserver/index.html. +- Terraform backend uses GCS state bucket/prefix. + +### GCS as GeoServer data source of truth +GeoServer configuration state (workspaces, stores, layers, styles) is not stored on ephemeral container or VM disk. It is persisted in GCS and mounted into the container via gcsfuse: + +- **Data directory bucket** (`geoserver_data_bucket`): mounted r/w at host path `geoserver_data_mount_point` (default `/mnt/disks/geoserver-data`), scoped to prefix `geoserver_data_only_dir` (default `data_dir`), then bind-mounted into the container at `/opt/geoserver_data`. This bucket is the authoritative source of truth for GeoServer config. +- **Surveys bucket** (`surveys_bucket`, optional): mounted read-only at host path `surveys_mount_point` (default `/mnt/disks/geoserver-surveys`) and exposed inside the container at `surveys_container_mount_point` (default `/opt/geoserver_data/surveys`). Used for GeoServer raster asset access. + +VM service account (`geoserver-vm`) is granted `roles/storage.objectViewer` on both buckets via IAM resources in main.tf. GCS object versioning is the implicit backup mechanism for GeoServer config, but whether versioning is enabled on the live bucket is unknown. + + +## 6) Unknowns / Gaps Captured +- **Partially known**: Source of truth for GeoServer config is `geoserver_data_bucket` (GCS, mounted via gcsfuse — see section 5). Unknown: which specific bucket is live in production, and whether workspace/layer/style writes originate from admin UI or REST API calls. +- Unknown authn/authz posture for GeoServer admin/API and public endpoints (beyond LB exposure and SSH admin CIDR). +- Unknown SLOs, observability dashboards, alerting, and incident ownership for GeoServer service. +- **Partially known**: GeoServer config backup relies on GCS object versioning on `geoserver_data_bucket`. Unknown: whether versioning is enabled, what retention policy exists, and whether a restore drill has been performed. +- Unknown rollout status of ADR recommendation to make GeoServer the primary delivery tier. +- Unknown whether IaC in geoserver_iac is fully in sync with deployed infra (state file in repo is non-authoritative and mixed local artifacts exist). + +## 7) Current-State Conclusion +GeoServer is implemented and integrated enough to support AEM raster publication and downstream service-link generation, with a dedicated GCP deployment path defined in Terraform. However, the primary OGC feature API in this application remains pygeoapi-based, so current architecture is hybrid rather than fully GeoServer-centric. The largest unknowns are operational governance (config-as-code, ownership, observability, security controls) and the true production cutover status. diff --git a/docs/geoserver-current-state-doc-2026-05-18.md b/docs/geoserver-current-state-doc-2026-05-18.md new file mode 100644 index 000000000..a857add2c --- /dev/null +++ b/docs/geoserver-current-state-doc-2026-05-18.md @@ -0,0 +1,98 @@ +# GeoServer Current State Documentation + +**Prepared by:** Jake Ross +**Date:** 2026-05-18 +**Meeting / source:** geophysical/poc branch — code review + IaC analysis + +--- + +**Purpose.** Capture the current state of GeoServer integration in plain language: what it is, how data moves through it, what is working, what is broken, and what decisions are still open. + +--- + +## 1. Summary (Current State) + +GeoServer is a dedicated geospatial publishing tier integrated into the OcotilloAPI platform. It handles AEM raster (GeoTIFF) publication and exposes WMS/WFS/WCS service links for STAC collections. It runs as a Docker container on a GCP VM, backed by GCS buckets for persistent config and raster data. The primary OGC API feature surface (/ogcapi) still runs on pygeoapi, so the current architecture is hybrid — GeoServer handles rasters and OWS services, pygeoapi handles vector feature API. GeoServer also connects to PostGIS as a vector data store. + +--- + +## 2. What Problem Does It Solve? + +- **Original problem:** No OGC-compliant raster tile/coverage service existed for AEM geophysical survey data being ingested into the platform. +- **Current problem:** AEM GeoTIFF ingest pipeline needs a publish target that exposes WMS/WFS/WCS endpoints; STAC collection records need service links for map/feature/coverage consumers. GeoServer fills both roles. +- **Primary users / consumers:** + - AEM ingest operators (run batch and single-file ingest that triggers GeoServer publish) + - GIS/data consumers (access WMS/WFS/WCS links embedded in STAC collection assets) + - Platform/infra maintainers (operate GCP + Terraform deployment) + +--- + +## 3. Inputs / Outputs + +| Input | Ingestion Layer | Raw Data Storage | Transformation & Load | Clean Data Storage | Service | +|---|---|---|---|---|---| +| AEM GeoTIFF files | `services/aem_batch.py`, `services/aem_asset_ingest.py` | GCS (`surveys_bucket`) | `services/geoserver_helper.py` — registers external GeoTIFF as GeoServer coverage store via REST | GeoServer data dir in GCS (`geoserver_data_bucket`) | WMS / WCS via HTTPS LB (`geoserver.newmexicowaterdata.org`) | +| PostGIS vector data | GeoServer admin / REST config | PostgreSQL + PostGIS DB | GeoServer PostGIS data store connection | GeoServer workspace / layer config in GCS data dir | WFS via HTTPS LB | +| STAC collection build | `services/aem_stac.py` | PostgreSQL (asset + survey records) | Generates WMS/WFS/WCS hrefs from `GEOSERVER_PUBLIC_URL` + `GEOSERVER_WORKSPACE` | STAC JSON artifact | STAC collection served by OcotilloAPI | + +--- + +## 4. Main Components + +| Component | Role today | Owner / repo | Status | +|---|---|---|---| +| `services/geoserver_helper.py` | Idempotent GeoServer REST publish helper — workspace + coverage store registration, publish status tracking | OcotilloAPI / geophysical/poc | Active | +| `services/aem_asset_ingest.py` | Single-file AEM ingest — invokes publish_geotiff_asset | OcotilloAPI / geophysical/poc | Active | +| `services/aem_batch.py` | Batch AEM orchestration — routes GeoTIFFs through upload + GeoServer publish | OcotilloAPI / geophysical/poc | Active | +| `services/aem_stac.py` | STAC service-link generation — adds WMS/WFS/WCS hrefs to collection assets | OcotilloAPI / geophysical/poc | Active | +| `db/asset.py` publish fields | DB tracking for publish outcome per asset (`publish_status`, `publish_layer_name`, etc.) | OcotilloAPI / geophysical/poc | Active | +| `geoserver_iac/` | Terraform IaC — GCP VM + instance group + HTTPS LB + gcsfuse bucket mounts | OcotilloAPI / geophysical/poc | Defined; live state unknown | +| GeoServer container | `docker.osgeo.org/geoserver:2.28.0` running on GCP VM | GCP / geoserver_iac | Assumed active; not confirmed | +| GCS `geoserver_data_bucket` | Source of truth for GeoServer config (workspaces, stores, layers, styles) — gcsfuse r/w mount at `/opt/geoserver_data` | GCP | Active; bucket name unknown | +| GCS `surveys_bucket` | Raster source data for GeoServer coverage stores — gcsfuse r/o mount at `/opt/geoserver_data/surveys` | GCP | Active; bucket name unknown | +| PostgreSQL + PostGIS | Vector data store — GeoServer connects directly for WFS feature serving | OcotilloAPI DB | Active | +| HTTPS Load Balancer | Public front door for GeoServer at `geoserver.newmexicowaterdata.org/geoserver` | GCP / geoserver_iac | Defined in IaC | + +--- + +## 5. Key Behavior + +**Flow A — AEM GeoTIFF publish:** +1. Ingest uploads GeoTIFF to GCS (`surveys_bucket`). +2. GeoServer helper resolves mounted path (`GEOSERVER_RASTER_SOURCE_ROOT` + storage path). +3. Helper ensures workspace exists; registers external GeoTIFF as coverage store if absent. +4. Publish outcome written to asset `publish_*` columns in DB. + +**Flow B — STAC service links:** +1. STAC collection build reads asset + survey records from DB. +2. If `GEOSERVER_PUBLIC_URL` and `GEOSERVER_WORKSPACE` are set, collection assets include WMS/WFS/WCS hrefs. +3. GIS clients discover GeoServer endpoints via those links. + +**Flow C — Hybrid serving (current):** +- `/ogcapi` OGC Features served by pygeoapi (unchanged). +- GeoServer serves AEM raster publication and related OWS endpoints. +- PostGIS vector data available to GeoServer as a data store alongside raster coverage stores. + +**Config persistence:** +GeoServer data directory is gcsfuse-mounted from `geoserver_data_bucket` into the container at `/opt/geoserver_data`. All workspace/store/layer/style changes persist to GCS, not to ephemeral container disk. + +--- + +## 6. Known Problems + +- No workspace/layer/style lifecycle as code — GeoServer config changes (e.g. new PostGIS data stores, style edits) go directly to GCS data dir via admin UI or REST, with no Git-backed review gate or promotion process. +- IaC state is non-authoritative in the repo (`terraform.tfstate` is empty; `.tfstate.backup` is a stale local snapshot). True deployed state is unknown. +- Hybrid serving architecture (pygeoapi + GeoServer) is unresolved — ADR recommending GeoServer as primary delivery tier is still marked Proposed. +- No observability: no confirmed dashboards, alerts, or on-call ownership for GeoServer service health or publish failure rates. + +--- + +## 7. Open Decisions / Questions + +- Which GCS buckets are the live `geoserver_data_bucket` and `surveys_bucket` in production? +- Are workspace/store/layer writes happening via admin UI (writes to GCS data dir) or via REST API calls from code? +- Is GCS object versioning enabled on `geoserver_data_bucket`? Has a restore drill been performed? +- What is the current production traffic split between GeoServer OWS endpoints and pygeoapi /ogcapi? +- What is the status of the ADR to make GeoServer the primary delivery tier — is there a timeline? +- What is the authn/authz posture for the GeoServer admin endpoint and public OWS endpoints? +- Who owns GeoServer incident response and what are the SLOs? diff --git a/docs/geoserver-verification-checklist-2026-05-18.md b/docs/geoserver-verification-checklist-2026-05-18.md new file mode 100644 index 000000000..edffa9c9c --- /dev/null +++ b/docs/geoserver-verification-checklist-2026-05-18.md @@ -0,0 +1,101 @@ +# GeoServer Verification Checklist (2026-05-18) + +Use this checklist to close the unknowns identified in the current-state analysis. + +## A. Production Usage Verification +- [ ] Pull 30-day request logs for: + - GeoServer LB endpoint + - /ogcapi endpoints +- [ ] Quantify request volume by endpoint family (WMS/WFS/WCS vs /ogcapi). +- [ ] Identify top clients (service accounts, applications, external consumers). +- [ ] Confirm which datasets are actively consumed from GeoServer vs pygeoapi. + +Evidence to capture: +- URL and date range of logs queried. +- Table of volumes by endpoint family. +- Named systems consuming each interface. + +## B. GeoServer Configuration Governance + +**Known from IaC**: GeoServer data directory (workspaces, stores, layers, styles) is persisted in GCS and mounted into the container via gcsfuse at `/opt/geoserver_data`. The `geoserver_data_bucket` Terraform variable names the authoritative bucket; the `geoserver_data_only_dir` prefix (default `data_dir`) scopes the mount within that bucket. An optional second bucket (`surveys_bucket`) is mounted read-only at `/opt/geoserver_data/surveys` for raster asset access. The GCS buckets are therefore the source of truth for GeoServer configuration state — not the container filesystem and not a separate config-as-code repo. + +- [ ] Confirm which GCS bucket(s) are the live `geoserver_data_bucket` and `surveys_bucket` in production. +- [ ] Confirm whether workspaces/stores/layers are created via admin UI writes (persisted to GCS through the mount) or via REST API calls from code. +- [ ] Document whether changes are: + - API-driven from code, + - manual in admin UI (changes go directly to GCS data dir), + - imported from data directory snapshots. +- [ ] Define promotion process across environments (dev/staging/prod). +- [ ] Define review gate (PR, change ticket, approvals). + +Evidence to capture: +- Bucket names for each environment. +- Owner(s) and approver group. +- Step-by-step promotion procedure. +- Rollback procedure and tested example. + +## C. Security and Access Controls +- [ ] Verify public exposure scope (only intended paths/services). +- [ ] Verify GeoServer admin endpoint restrictions. +- [ ] Verify credential rotation policy for GEOSERVER_USERNAME/PASSWORD. +- [ ] Verify VM and container patch/update cadence. +- [ ] Verify SSH access controls still match current admin roster. + +Evidence to capture: +- Current access matrix (who can do what). +- Rotation schedule and last rotation date. +- Hardening checklist status. + +## D. Observability and Operations +- [ ] Confirm logging coverage for publish attempts and failures. +- [ ] Confirm alerts for: + - LB health-check failure + - GeoServer process/container down + - repeated publish failures +- [ ] Confirm dashboards for latency/error rate/throughput. +- [ ] Confirm incident ownership and escalation path. + +Evidence to capture: +- Alert definitions and destinations. +- Dashboard links. +- On-call ownership mapping. + +## E. Data Resilience and Recovery + +**Known from IaC**: GeoServer config state lives in `geoserver_data_bucket` (gcsfuse mount). GCS object versioning or bucket-level backup policy is therefore the backup mechanism for GeoServer config — no separate data-directory backup step exists unless versioning is enabled on that bucket. + +- [ ] Confirm GCS versioning and/or object lifecycle policy on `geoserver_data_bucket`. +- [ ] Confirm backup policy for GeoServer data directory (backed by GCS — see section B). +- [ ] Confirm restore drill has been tested and documented. +- [ ] Confirm RPO/RTO targets and current achieved posture. + +Evidence to capture: +- Backup schedule and retention. +- Last successful restore test date. +- Recovery runbook location. + +## F. ADR/Cutover Status +- [ ] Confirm current status of ADR3 recommendation in practice. +- [ ] List datasets already migrated to GeoServer. +- [ ] List datasets still on pygeoapi only. +- [ ] Define explicit transition milestones and decision gates. + +Evidence to capture: +- Dataset inventory by serving surface. +- Owner and timeline for each migration wave. + +## G. IaC and Environment Consistency +- [ ] Confirm Terraform state is authoritative and current for active environment. +- [ ] Confirm repo IaC variables match deployed values (domain, buckets, mounts, image). +- [ ] Remove ambiguity from local state artifacts in repo workflows. +- [ ] Capture environment bootstrap steps needed for repeatable deploy. + +Evidence to capture: +- Terraform state backend details and last apply metadata. +- Drift report summary. +- Environment parity table (desired vs actual). + +## Exit Criteria +- [ ] Every checklist section has an owner and due date. +- [ ] Unknowns list is reduced to zero or converted into tracked risks. +- [ ] Final architecture decision (hybrid vs full GeoServer primary) is documented with measurable readiness criteria. From 9e91d181e1bcd162ce54e5cd54bd5340114a4ce2 Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 7 Jul 2026 14:56:25 -0600 Subject: [PATCH 133/160] docs: add OpenStatus as status-page option and reframe for public status page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Option 4 (OpenStatus) — a status-page-first, AGPL-3.0 tool with public status pages, subscriber notifications, and monitoring-as-code (YAML/Terraform) that fits this repo's config-as-code habits. Reframes the doc's goal to include a public status page for users and developers, adds a probe-independence note (keep the monitor external to GCP so the page survives platform outages), extends the comparison table, and re-weights the recommendation toward OpenStatus when the public status page is the priority. Co-Authored-By: Claude Opus 4.8 --- docs/api-monitoring-options.md | 81 ++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/docs/api-monitoring-options.md b/docs/api-monitoring-options.md index 2dbbc611a..f2ccd3f9a 100644 --- a/docs/api-monitoring-options.md +++ b/docs/api-monitoring-options.md @@ -1,8 +1,8 @@ # API Monitoring Options for OcotilloAPI Production -**Purpose:** Evaluate three open-source uptime and health-check monitoring tools for watching production deployments of OcotilloAPI (FastAPI + PostgreSQL/PostGIS). +**Purpose:** Evaluate open-source uptime and health-check monitoring tools for watching production deployments of OcotilloAPI (FastAPI + PostgreSQL/PostGIS), and — added in the 2026-07-07 revision — tools for a **public status page** so users and developers can see live service health. -**Focus:** Uptime and health checks — is the API reachable, is it responding within acceptable latency, and does its health endpoint report the database and dependencies as healthy. This document does not cover full APM/tracing platforms (SigNoz, Grafana Tempo, etc.), which are a heavier and separate decision. +**Focus:** Uptime and health checks — is the API reachable, is it responding within acceptable latency, and does its health endpoint report the database and dependencies as healthy — plus a public, subscribable status page. This document does not cover full APM/tracing platforms (SigNoz, Grafana Tempo, etc.), which are a heavier and separate decision. **Date:** 2026-07-07 @@ -16,8 +16,11 @@ OcotilloAPI is a FastAPI service backed by PostgreSQL + PostGIS. A practical upt - **A health endpoint** — expose a `/health` (and optionally `/health/db`) route in FastAPI that checks database connectivity and returns JSON like `{"status": "ok", "db": "ok"}`. All three tools below can assert against that JSON. - **Response-time thresholds** — flag slow spatial queries before users notice. - **Alerting** to a channel the team watches (email, Slack, PagerDuty). +- **A public status page** — a page users and developers can visit to see current uptime, incidents, and planned maintenance, ideally with email/RSS/webhook subscriptions. - **Self-hostable** alongside the existing Docker Compose stack, and ideally versioned in the repo. +> **Probe independence.** A status page exists to be trustworthy *when the service is down*. If the monitor runs inside the same GCP project/App Engine service it watches, a platform-level outage takes the status page down with it (correlated failure). Run the probe from an **external vantage** — a hosted/SaaS checker, or a self-hosted probe on separate infrastructure — regardless of which tool below is chosen. + A recommended FastAPI health route to monitor: ```python @@ -127,32 +130,68 @@ Best long-term fit **if** OcotilloAPI already runs, or plans to run, Prometheus --- +## Option 4 — OpenStatus + +**License:** AGPL-3.0 · **Language:** TypeScript (Next.js) · **Repo:** github.com/openstatusHQ/openstatus + +OpenStatus is a **status-page-first** platform that combines synthetic uptime monitoring, public status pages, and incident/maintenance communication in one product. Unlike the three options above — where a status page is either a side feature (Uptime Kuma, Gatus) or absent (Blackbox Exporter) — the public status page is OpenStatus's primary deliverable. Available as managed SaaS or fully self-hosted. + +**Relevant capabilities** + +- HTTP/HTTPS (REST/GraphQL) and TCP monitoring with assertions on status code, response time, headers, and response body — maps onto the existing `/health` route (`core/app.py`, returns `{"status": "ok", "version": ...}`). +- **Public status page** with custom domains, branded themes, timestamped incident reports, and scheduled maintenance windows. Automatic status updates during incidents (no manual toggling). +- **Subscriber notifications** on the status page: email, RSS/Atom, and webhooks — so users and developers self-subscribe to updates. +- **Monitoring as code**: YAML config, a Terraform provider, a CLI, and GitHub Actions integration — checks live in the repo, consistent with this project's release-please / templated-`app.yaml` / `geoserver_iac/` Terraform habits. +- Alerts via Slack, Discord, PagerDuty, email, and webhooks. A RESTful (OpenAPI) API for automation. +- SaaS probes run from 28 regions across 3 cloud providers; self-hosting supports private probe locations behind a firewall. +- Self-host ships as Docker Compose. A **lightweight status-page-only** mode runs just four services (database, migration runner, dashboard, status page) for teams that only want the public page. + +**Fit for OcotilloAPI** + +The best fit specifically for the "users and developers can see status" goal, because the public status page is first-class rather than bolted on, and because monitoring-as-code (YAML + Terraform) matches how this repo already manages deployment config. Lowest-effort path: the SaaS free tier watching `https:///health`, published to a custom-domain status page — zero infrastructure and an external probe vantage by default. + +**Trade-offs** + +- **AGPL-3.0** copyleft. Fine for internal self-hosting; only a concern if the code is modified *and redistributed*. +- SaaS free tier is limited to **one monitor, one status page, 10-minute checks**; more monitors or faster intervals start at ~$30/month. Self-hosting removes these limits but requires running (and keeping independent) the stack. +- Self-hosting the probe on the same infrastructure as OcotilloAPI reintroduces the correlated-failure problem noted above — keep the probe external, or use SaaS. +- Newer and smaller-community than Uptime Kuma; maintained by a small bootstrapped team. + +--- + ## Comparison -| Criterion | Uptime Kuma | Gatus | Blackbox Exporter | -|---|---|---|---| -| License | MIT | Apache 2.0 | Apache 2.0 | -| Configuration | Web UI (stored in DB) | YAML (config-as-code) | YAML + Prometheus config | -| JSON health-body assertion | Yes (JSON query) | Yes (`[BODY]` conditions) | Regex on body | -| TLS expiry checks | Yes | Yes | Yes | -| Response-time thresholds | Yes | Yes | Yes (via Prometheus rules) | -| Built-in dashboard | Yes (rich + status page) | Yes (lightweight) | No (needs Grafana) | -| Alerting | Many integrations built in | Many integrations built in | Via Alertmanager | -| Setup effort | Low (1 container) | Low (1 container) | High (full stack) | -| GitOps / versioned config | No (community tooling) | Yes (native) | Yes | -| Best when… | Want a UI + status page fast | Want config in the repo | Already run Prometheus | +| Criterion | Uptime Kuma | Gatus | Blackbox Exporter | OpenStatus | +|---|---|---|---|---| +| License | MIT | Apache 2.0 | Apache 2.0 | AGPL-3.0 | +| Configuration | Web UI (stored in DB) | YAML (config-as-code) | YAML + Prometheus config | YAML / Terraform / UI | +| JSON health-body assertion | Yes (JSON query) | Yes (`[BODY]` conditions) | Regex on body | Yes (body assertions) | +| TLS expiry checks | Yes | Yes | Yes | Yes | +| Response-time thresholds | Yes | Yes | Yes (via Prometheus rules) | Yes | +| Public status page | Yes | Basic | No | **Yes (first-class)** | +| Status-page subscriptions | Limited | No | No | Email / RSS / webhook | +| Built-in dashboard | Yes (rich + status page) | Yes (lightweight) | No (needs Grafana) | Yes (status page + dashboard) | +| Alerting | Many integrations built in | Many integrations built in | Via Alertmanager | Slack/Discord/PagerDuty/email/webhook | +| Setup effort | Low (1 container) | Low (1 container) | High (full stack) | Low (SaaS) / Medium (self-host) | +| GitOps / versioned config | No (community tooling) | Yes (native) | Yes | Yes (YAML + Terraform) | +| Hosted SaaS option | No | No | No | Yes (free tier + paid) | +| Best when… | Want a UI + status page fast | Want config in the repo | Already run Prometheus | Want a public status page + config-as-code | --- ## Recommendation -For OcotilloAPI's stated goal — uptime and health-check monitoring of a production FastAPI + PostGIS service — the pragmatic ranking: +The goal has two parts: (a) internal uptime/health monitoring and alerting, and (b) a **public status page** for users and developers. The right pick depends on which dominates. + +**If the public status page is the priority (the current goal): OpenStatus.** The status page is first-class — custom domain, incident timeline, maintenance windows, and email/RSS/webhook subscriptions — and monitoring-as-code (YAML + Terraform) matches this repo's existing config-as-code habits. Fastest path: the **SaaS free tier** watching `https:///health`, published to a custom-domain status page. Zero infrastructure, and the probe runs from an external vantage by default (satisfying the probe-independence requirement). Upgrade to a paid tier or self-host only when more monitors or sub-10-minute intervals are needed. + +**If config-as-code monitoring/alerting matters more than the public page: Gatus.** One repo-committed `config.yaml`, one container, JSON health assertions, flap-resistant alerting. Its status page is thinner than OpenStatus's, so pair it with OpenStatus (or promote OpenStatus) if the public page becomes central. + +**If a friendly point-and-click UI is the priority: Uptime Kuma.** Quickest UI-driven setup and a decent status page, at the cost of no native GitOps. -1. **Gatus** if the team values keeping monitoring configuration versioned in the repo alongside the code (consistent with this project's alembic/config-as-code habits). One YAML file, one container, JSON health assertions, and flap-resistant alerting. -2. **Uptime Kuma** if a friendly UI and a public/internal status page matter more than GitOps, and the team wants the quickest possible setup. -3. **Blackbox Exporter** only if (or once) OcotilloAPI adopts Prometheus for broader infrastructure metrics — then fold endpoint monitoring into that stack rather than running a separate tool. +**Blackbox Exporter** remains the choice only if (or once) OcotilloAPI adopts Prometheus for broader infrastructure metrics — then fold endpoint monitoring into that stack rather than running a separate tool. It has no status page of its own. -A reasonable starting move: add a `/health` route to FastAPI that verifies PostGIS connectivity, then stand up **Gatus** as a container in the existing Docker Compose stack with a repo-committed `config.yaml`. Revisit Blackbox Exporter if/when a Prometheus stack is introduced. +A reasonable starting move for the stated goal: stand up **OpenStatus** (SaaS free tier to start) monitoring the existing `/health` route, publish a public status page on a custom domain, and commit the monitor definition (YAML/Terraform) to the repo. Keep the probe external to GCP so the page stays up during a platform outage. --- @@ -167,3 +206,7 @@ A reasonable starting move: add a `/health` route to FastAPI that verifies PostG - [Prometheus Blackbox Exporter — GitHub](https://github.com/prometheus/blackbox_exporter) - [Prometheus Blackbox Exporter: Ultimate Guide (SolarWinds)](https://www.solarwinds.com/blog/prometheus-blackbox-exporter) - [How to Use Alertmanager and Blackbox Exporter to Monitor Your Web Server (DigitalOcean)](https://www.digitalocean.com/community/tutorials/how-to-use-alertmanager-and-blackbox-exporter-to-monitor-your-web-server-on-ubuntu-16-04) +- [OpenStatus — official site](https://www.openstatus.dev/) +- [OpenStatus — GitHub](https://github.com/openstatusHQ/openstatus) +- [OpenStatus — self-hosting guide](https://docs.openstatus.dev/guides/self-hosting-openstatus/) +- [OpenStatus — self-host status page only (lightweight)](https://docs.openstatus.dev/guides/self-host-status-page-only/) From 73d0032280a9dd5d0d182d382530217e383807da Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 7 Jul 2026 15:02:43 -0600 Subject: [PATCH 134/160] docs: rework monitoring doc around a shortlist and decision-first recommendation Restructure to lead with the decision: TL;DR, an explicit five-tool shortlist, and a polished recommendation with a concrete OpenStatus rollout plan. Broaden from the original three self-hosted monitors to the full status-page landscape (OpenStatus, Better Stack, Instatus, Gatus, Uptime Kuma, OneUptime, Checkmate, Cachet, Statping-ng, Vigil, UptimeRobot, Atlassian Statuspage, Checkly, incident.io, Blackbox Exporter), reframed around a public status page for users and developers. Add a probe-independence constraint (keep the monitor external to GCP), note that the live /health returns no DB check and should be extended, add a focused comparison table and a when-to-pick-what decision guide. Co-Authored-By: Claude Opus 4.8 --- docs/api-monitoring-options.md | 249 ++++++++++++++------------------- 1 file changed, 102 insertions(+), 147 deletions(-) diff --git a/docs/api-monitoring-options.md b/docs/api-monitoring-options.md index f2ccd3f9a..e9c57ce64 100644 --- a/docs/api-monitoring-options.md +++ b/docs/api-monitoring-options.md @@ -1,212 +1,167 @@ -# API Monitoring Options for OcotilloAPI Production +# API Monitoring & Public Status Page — Options and Recommendation -**Purpose:** Evaluate open-source uptime and health-check monitoring tools for watching production deployments of OcotilloAPI (FastAPI + PostgreSQL/PostGIS), and — added in the 2026-07-07 revision — tools for a **public status page** so users and developers can see live service health. +**Purpose:** Choose how to (1) monitor the OcotilloAPI production deployment (FastAPI + PostgreSQL/PostGIS on Google App Engine) and (2) publish a **public status page** so users and developers can see live service health, incidents, and planned maintenance. -**Focus:** Uptime and health checks — is the API reachable, is it responding within acceptable latency, and does its health endpoint report the database and dependencies as healthy — plus a public, subscribable status page. This document does not cover full APM/tracing platforms (SigNoz, Grafana Tempo, etc.), which are a heavier and separate decision. +**Out of scope:** Full APM/tracing platforms (SigNoz, Grafana Tempo, Datadog APM). Those are a heavier, separate decision. This doc is about uptime/health checks and a status page. **Date:** 2026-07-07 --- -## What OcotilloAPI needs monitored +## TL;DR -OcotilloAPI is a FastAPI service backed by PostgreSQL + PostGIS. A practical uptime/health monitor for this stack should cover: +- **Recommended: [OpenStatus](https://www.openstatus.dev/).** It is the only option that combines a **first-class public status page**, **monitoring-as-code** (YAML + Terraform, matching this repo's config-as-code habits), and a **SaaS-to-self-host path** — start managed, migrate later without switching tools. +- **Start on the SaaS free tier**, monitoring `https:///health`, published to a custom-domain status page. Zero infrastructure, external probe vantage by default. Commit the monitor definition (YAML/Terraform) to the repo. +- **If config-as-code is not a requirement**, the fastest polished alternatives are **Better Stack** (free, all-in-one) or **Instatus** (best-looking page, ~$15/mo). +- **Non-negotiable constraint:** the probe must run **outside GCP**. A status page that goes dark during a platform outage is worthless — see [Probe independence](#probe-independence). -- **Reachability** of the public API (HTTP/HTTPS status codes, TLS certificate validity/expiry). -- **A health endpoint** — expose a `/health` (and optionally `/health/db`) route in FastAPI that checks database connectivity and returns JSON like `{"status": "ok", "db": "ok"}`. All three tools below can assert against that JSON. +--- + +## What we need + +OcotilloAPI is a FastAPI service backed by PostgreSQL + PostGIS, deployed on App Engine. The monitoring + status-page solution should cover: + +- **Reachability** of the public API — HTTP/HTTPS status codes and TLS certificate validity/expiry. +- **Health-endpoint assertion** — check `/health` and assert the JSON body, not just a 200. - **Response-time thresholds** — flag slow spatial queries before users notice. - **Alerting** to a channel the team watches (email, Slack, PagerDuty). -- **A public status page** — a page users and developers can visit to see current uptime, incidents, and planned maintenance, ideally with email/RSS/webhook subscriptions. -- **Self-hostable** alongside the existing Docker Compose stack, and ideally versioned in the repo. +- **A public status page** — a branded page users and developers can visit for current uptime, incident history, and scheduled maintenance, with email/RSS/webhook subscriptions. +- **Config-as-code** (preferred) — monitor definitions versioned in the repo, consistent with this project's release-please, templated `app.yaml`, and `geoserver_iac/` Terraform. -> **Probe independence.** A status page exists to be trustworthy *when the service is down*. If the monitor runs inside the same GCP project/App Engine service it watches, a platform-level outage takes the status page down with it (correlated failure). Run the probe from an **external vantage** — a hosted/SaaS checker, or a self-hosted probe on separate infrastructure — regardless of which tool below is chosen. +### Health endpoint -A recommended FastAPI health route to monitor: +A monitor is only as good as what it checks. `/health` already exists (`core/app.py`) but currently returns `{"status": "ok", "version": ...}` **without** touching the database — a 200 from it does not prove PostGIS is reachable. Before wiring up monitoring, extend it to verify DB connectivity so the status page reflects real health: ```python -from fastapi import APIRouter, Depends -from sqlalchemy import text - -router = APIRouter() - @router.get("/health") async def health(session=Depends(get_session)): await session.execute(text("SELECT 1")) - return {"status": "ok", "db": "ok"} + return {"status": "ok", "db": "ok", "version": settings.version} ``` ---- - -## Option 1 — Uptime Kuma - -**License:** MIT · **Language:** Node.js/Vue · **Repo:** github.com/louislam/uptime-kuma (~76k+ GitHub stars — the most popular self-hosted uptime monitor) - -Uptime Kuma is a UI-driven, self-hosted uptime monitor. You add and configure monitors through a polished web dashboard rather than a config file. +Every tool below can assert on `"db": "ok"` (JSON query or body match). -**Relevant capabilities** +### Probe independence -- Monitor types include HTTP/HTTPS, TCP port, ping, DNS, keyword-in-response, **HTTP(S) JSON query**, database checks, and Docker containers. -- HTTP monitor checks the status code, can assert a **keyword** or a **JSON query** against the response body (ideal for asserting `"db": "ok"` from the health route), and warns on certificate expiry within a configurable threshold. -- Built-in status pages, per-monitor history/uptime %, and a large set of notification integrations (Slack, email/SMTP, Telegram, Discord, PagerDuty, webhooks, and many more). -- v2.0 (Oct 2025) added MariaDB backend support, rootless Docker images, refreshed UI. v2.1 (Feb 2026) added Globalping worldwide probes and domain-expiry monitoring. +A status page exists to be trustworthy **when the service is down**. If the monitor runs inside the same GCP project / App Engine service it watches, a platform-level outage takes the status page down with it — exactly when users need it. Therefore: -**Fit for OcotilloAPI** +- Prefer a **SaaS probe** (checks from external regions), or +- Self-host the probe on **separate infrastructure** (different provider/region), never on the monitored App Engine service. -Fastest path to "is the API up and is the DB healthy." Drops into the existing Docker Compose stack as one container, and the JSON-query monitor maps directly onto a FastAPI `/health` response. Best when the team wants a friendly UI and public status page with minimal setup. - -**Trade-offs** - -- Config lives in the app's own database, not in the repo — no native config-as-code/GitOps (community tools like the `uptime-kuma-api` Python package or `uptime-kuma-web-api` can script setup, but it is not first-class). -- No official REST API; automation goes through the Socket.IO API. -- Single-instance architecture; not built for horizontally-scaled HA. +This rules out running the monitor as just another container in the production stack. --- -## Option 2 — Gatus - -**License:** Apache 2.0 · **Language:** Go · **Repo:** github.com/TwiN/gatus - -Gatus is a lightweight, developer-oriented health dashboard where every monitored endpoint, condition, and alert rule is declared in a **YAML file**. That makes it a natural fit for GitOps — monitoring changes become versioned commits. - -**Relevant capabilities** +## Recommendation -- Probes HTTP, TCP, ICMP, DNS, WebSocket, SSH, TLS, and STARTTLS endpoints on a schedule. -- Declarative **conditions** on status code, response time, response body (including JSON assertions, e.g. `[BODY].db == ok`), IP, and TLS certificate expiration. -- `failure-threshold` / `success-threshold` settings prevent alert flapping from intermittent blips. -- Alerting out of the box: Slack, Mattermost, PagerDuty, Twilio, Google Chat, Teams, Messagebird, plus custom providers. -- Built-in web dashboard with per-endpoint status, response-time history, and uptime % — no Grafana required for basic visualization. +**Adopt OpenStatus, starting on the managed SaaS free tier.** -**Fit for OcotilloAPI** +Rationale, weighted to the stated goal (public status page for users + developers): -Strong match for a team that already versions infrastructure. A single `config.yaml` lives in the repo next to OcotilloAPI, defining checks against `/health`, asserting the JSON body and a response-time ceiling, and firing alerts after N consecutive failures. Lightweight Go binary/container, low resource use. +1. **The status page is the product, not a side feature.** Custom domain, branded theme, timestamped incident reports, scheduled maintenance windows, and automatic status updates during incidents. Users and developers self-subscribe via email, RSS/Atom, or webhook. +2. **Monitoring-as-code.** Checks are defined in YAML with a Terraform provider, CLI, and GitHub Actions integration — the same config-as-code model already used for deployments here. Monitor changes become reviewable commits. +3. **No lock-in on hosting.** Begin on SaaS (zero infra, external probe vantage) and migrate to self-hosted later if cost or data-residency demands it — same tool, same config. Most tools force an either/or. +4. **Fits the endpoint we have.** HTTP checks assert on `/health` status, latency, and JSON body. -Example condition set: +**Trade-offs to accept:** AGPL-3.0 license (fine for internal self-hosting; matters only if the code is modified *and* redistributed); the SaaS free tier is limited to one monitor / one status page / 10-minute checks (≈$30/mo unlocks more monitors and faster intervals); smaller community than Uptime Kuma, maintained by a small bootstrapped team. -```yaml -endpoints: - - name: ocotillo-api-health - url: "https://api.example.org/health" - interval: 60s - conditions: - - "[STATUS] == 200" - - "[BODY].db == ok" - - "[RESPONSE_TIME] < 500" - alerts: - - type: slack - failure-threshold: 3 - success-threshold: 2 -``` +**If config-as-code is dropped as a requirement**, pick for speed instead: +- **Better Stack** — all-in-one uptime + incident + status page, generous free tier. +- **Instatus** — the best-looking, fastest-loading status page; ~$15/mo with basic monitoring included. -**Trade-offs** +### Rollout plan -- No point-and-click UI for adding monitors — everything is YAML (a feature for engineers, friction for non-technical stakeholders). -- Status-page/incident features are lighter than Uptime Kuma's. +1. Extend `/health` to verify PostGIS connectivity (snippet above). +2. Create an OpenStatus SaaS account (free tier). Add one HTTP monitor on `https:///health` asserting `[STATUS] == 200`, `[BODY].db == ok`, and a response-time ceiling. +3. Publish a public status page on a custom domain (e.g. `status.`); enable email/RSS subscriptions. +4. Wire alerts to the team's Slack (and PagerDuty if used). +5. Export the monitor as YAML/Terraform and commit it to the repo so the config is versioned. +6. Revisit self-hosting (or a paid tier) only when more monitors or sub-10-minute intervals are needed. --- -## Option 3 — Prometheus Blackbox Exporter - -**License:** Apache 2.0 · **Language:** Go · **Repo:** github.com/prometheus/blackbox_exporter (official Prometheus / CNCF component) - -The Blackbox Exporter probes endpoints externally and exposes the results as **Prometheus metrics**. It is the production-grade, standards-based choice — but it is a component, not a standalone product: it assumes (or introduces) a Prometheus + Alertmanager stack, usually with Grafana for dashboards. +## Shortlist -**Relevant capabilities** +The five worth serious consideration, in priority order for this project: -- Probes over HTTP, HTTPS, DNS, TCP, ICMP, and gRPC. -- HTTP probe defaults to GET expecting 2xx; configurable for other methods, expected status codes, **basic/bearer auth**, custom headers, body matching (regex on response), and proxies. -- Emits metrics such as `probe_success`, `probe_duration_seconds`, `probe_http_status_code`, and `probe_ssl_earliest_cert_expiry` (TLS expiry timestamp). -- Alerting via Prometheus alerting rules → Alertmanager (routing, grouping, silencing, dedup) to Slack, PagerDuty, email, etc. -- Multi-target / multi-region probing and long-term metric retention when paired with the Prometheus stack. +1. **OpenStatus** *(recommended)* — status-page-first, config-as-code, SaaS-or-self-host. Best overall fit. +2. **Better Stack** *(SaaS, zero-ops)* — all-in-one, generous free tier; pick if you want managed and don't need config-as-code. +3. **Instatus** *(SaaS, prettiest page)* — cheapest polished public page; monitoring is basic. +4. **Gatus** *(self-host, GitOps)* — excellent YAML health checks, but a thin status page; pick if internal monitoring matters more than the public page. +5. **Uptime Kuma** *(self-host, easiest UI)* — friendly dashboard and a decent status page, but single-location probe and no native GitOps. -**Fit for OcotilloAPI** - -Best long-term fit **if** OcotilloAPI already runs, or plans to run, Prometheus for infrastructure metrics. Then endpoint uptime, latency, and cert expiry become just more series alongside app and host metrics, with unified Grafana dashboards and Alertmanager routing. Body-regex matching can assert the health-endpoint payload. - -**Trade-offs** - -- Heaviest setup by far: Blackbox Exporter + Prometheus + Alertmanager (+ Grafana) to reach parity with what Uptime Kuma or Gatus give in one container. -- No built-in status page or friendly UI on its own. -- Overkill if uptime/health is the only goal and there is no existing Prometheus footprint. +Everything else below is context for why these five rise to the top. --- -## Option 4 — OpenStatus - -**License:** AGPL-3.0 · **Language:** TypeScript (Next.js) · **Repo:** github.com/openstatusHQ/openstatus - -OpenStatus is a **status-page-first** platform that combines synthetic uptime monitoring, public status pages, and incident/maintenance communication in one product. Unlike the three options above — where a status page is either a side feature (Uptime Kuma, Gatus) or absent (Blackbox Exporter) — the public status page is OpenStatus's primary deliverable. Available as managed SaaS or fully self-hosted. +## Full catalog -**Relevant capabilities** +### Self-hosted, open source -- HTTP/HTTPS (REST/GraphQL) and TCP monitoring with assertions on status code, response time, headers, and response body — maps onto the existing `/health` route (`core/app.py`, returns `{"status": "ok", "version": ...}`). -- **Public status page** with custom domains, branded themes, timestamped incident reports, and scheduled maintenance windows. Automatic status updates during incidents (no manual toggling). -- **Subscriber notifications** on the status page: email, RSS/Atom, and webhooks — so users and developers self-subscribe to updates. -- **Monitoring as code**: YAML config, a Terraform provider, a CLI, and GitHub Actions integration — checks live in the repo, consistent with this project's release-please / templated-`app.yaml` / `geoserver_iac/` Terraform habits. -- Alerts via Slack, Discord, PagerDuty, email, and webhooks. A RESTful (OpenAPI) API for automation. -- SaaS probes run from 28 regions across 3 cloud providers; self-hosting supports private probe locations behind a firewall. -- Self-host ships as Docker Compose. A **lightweight status-page-only** mode runs just four services (database, migration runner, dashboard, status page) for teams that only want the public page. +- **OpenStatus** — AGPL-3.0, TypeScript. Status page + monitoring + incidents; YAML/Terraform config; also offered as SaaS. *(Shortlisted #1.)* +- **Gatus** — Apache-2.0, Go. Config-as-code health checks with JSON body assertions and flap-resistant thresholds; lightweight built-in dashboard; thin status page. *(Shortlisted #4.)* +- **Uptime Kuma** — MIT, Node/Vue. ~76k★, the most popular self-hosted monitor; rich UI + status page; single-location probe; config lives in its DB (no native GitOps). *(Shortlisted #5.)* +- **OneUptime** — open-source all-in-one suite (monitoring + status page + incidents + on-call). The closest full-suite rival to OpenStatus; heavier to run; self-host or cloud. +- **Checkmate** (ex-BlueWave Uptime) — React/Node/Mongo; modern UI; active but newer/smaller community. +- **Cachet** — the original OSS status page. **Caution:** last release 2023, mid a v3.0 rewrite; status-page-only (needs a separate monitor). +- **Statping-ng** — all-in-one monitor + page; dated UI, uncertain maintenance. +- **Vigil** / **Uptimepage** — Rust, microservice-oriented; fast standalone binaries; sparser status-page polish, more infra effort. +- **Prometheus Blackbox Exporter** — Apache-2.0, Go. Probe-to-metrics; production-grade but a *component*, not a product — assumes a Prometheus + Alertmanager (+ Grafana) stack, and has **no status page**. Right choice only once Prometheus exists for broader infra metrics. -**Fit for OcotilloAPI** +### SaaS (external probe by default — satisfies probe independence) -The best fit specifically for the "users and developers can see status" goal, because the public status page is first-class rather than bolted on, and because monitoring-as-code (YAML + Terraform) matches how this repo already manages deployment config. Lowest-effort path: the SaaS free tier watching `https:///health`, published to a custom-domain status page — zero infrastructure and an external probe vantage by default. - -**Trade-offs** - -- **AGPL-3.0** copyleft. Fine for internal self-hosting; only a concern if the code is modified *and redistributed*. -- SaaS free tier is limited to **one monitor, one status page, 10-minute checks**; more monitors or faster intervals start at ~$30/month. Self-hosting removes these limits but requires running (and keeping independent) the stack. -- Self-hosting the probe on the same infrastructure as OcotilloAPI reintroduces the correlated-failure problem noted above — keep the probe external, or use SaaS. -- Newer and smaller-community than Uptime Kuma; maintained by a small bootstrapped team. +- **Better Stack** — all-in-one uptime + incident management + status page; generous free tier. *(Shortlisted #2.)* +- **Instatus** — best-looking, fastest status pages; ~$15/mo; basic monitoring included. *(Shortlisted #3.)* +- **UptimeRobot** — cheapest/free uptime + status pages; simple. +- **Atlassian Statuspage** — the polished incumbent for incident communication; pricier. +- **Checkly** — monitoring-as-code (Playwright, Terraform); very developer/GitOps-oriented; pricier and broader than a status page. +- **incident.io** — Slack-centric incident management + status page; priced for incident-heavy teams. --- ## Comparison -| Criterion | Uptime Kuma | Gatus | Blackbox Exporter | OpenStatus | -|---|---|---|---|---| -| License | MIT | Apache 2.0 | Apache 2.0 | AGPL-3.0 | -| Configuration | Web UI (stored in DB) | YAML (config-as-code) | YAML + Prometheus config | YAML / Terraform / UI | -| JSON health-body assertion | Yes (JSON query) | Yes (`[BODY]` conditions) | Regex on body | Yes (body assertions) | -| TLS expiry checks | Yes | Yes | Yes | Yes | -| Response-time thresholds | Yes | Yes | Yes (via Prometheus rules) | Yes | -| Public status page | Yes | Basic | No | **Yes (first-class)** | -| Status-page subscriptions | Limited | No | No | Email / RSS / webhook | -| Built-in dashboard | Yes (rich + status page) | Yes (lightweight) | No (needs Grafana) | Yes (status page + dashboard) | -| Alerting | Many integrations built in | Many integrations built in | Via Alertmanager | Slack/Discord/PagerDuty/email/webhook | -| Setup effort | Low (1 container) | Low (1 container) | High (full stack) | Low (SaaS) / Medium (self-host) | -| GitOps / versioned config | No (community tooling) | Yes (native) | Yes | Yes (YAML + Terraform) | -| Hosted SaaS option | No | No | No | Yes (free tier + paid) | -| Best when… | Want a UI + status page fast | Want config in the repo | Already run Prometheus | Want a public status page + config-as-code | +Focused on the shortlist plus the two reference points from the original evaluation (Blackbox, as the Prometheus path). + +| Criterion | OpenStatus | Better Stack | Instatus | Gatus | Uptime Kuma | Blackbox Exporter | +|---|---|---|---|---|---|---| +| Hosting | SaaS **or** self-host | SaaS | SaaS | Self-host | Self-host | Self-host | +| License (self-host) | AGPL-3.0 | — | — | Apache-2.0 | MIT | Apache-2.0 | +| Public status page | **First-class** | Yes | **Best-looking** | Basic | Yes | No | +| Status-page subscriptions | Email/RSS/webhook | Email/SMS/webhook | Email/Slack/webhook | No | Limited | No | +| Config-as-code | Yes (YAML + Terraform) | Partial (API/TF) | No | **Yes (native)** | No | Yes | +| JSON health-body assertion | Yes | Yes | Basic | Yes (`[BODY]`) | Yes (JSON query) | Regex on body | +| TLS expiry checks | Yes | Yes | Yes | Yes | Yes | Yes | +| Alerting | Slack/Discord/PagerDuty/email/webhook | Many + on-call | Slack/email/webhook | Many built-in | Many built-in | Via Alertmanager | +| External probe by default | Yes (SaaS) | Yes | Yes | No (you host) | No (you host) | No (you host) | +| Setup effort | Low (SaaS) / Med (self-host) | Low | Low | Low (1 container) | Low (1 container) | High (full stack) | +| Best when… | Public page **+** config-as-code | Managed all-in-one, free | Prettiest page, cheap | GitOps health checks | Friendly UI fast | Already run Prometheus | --- -## Recommendation - -The goal has two parts: (a) internal uptime/health monitoring and alerting, and (b) a **public status page** for users and developers. The right pick depends on which dominates. - -**If the public status page is the priority (the current goal): OpenStatus.** The status page is first-class — custom domain, incident timeline, maintenance windows, and email/RSS/webhook subscriptions — and monitoring-as-code (YAML + Terraform) matches this repo's existing config-as-code habits. Fastest path: the **SaaS free tier** watching `https:///health`, published to a custom-domain status page. Zero infrastructure, and the probe runs from an external vantage by default (satisfying the probe-independence requirement). Upgrade to a paid tier or self-host only when more monitors or sub-10-minute intervals are needed. +## Decision guide -**If config-as-code monitoring/alerting matters more than the public page: Gatus.** One repo-committed `config.yaml`, one container, JSON health assertions, flap-resistant alerting. Its status page is thinner than OpenStatus's, so pair it with OpenStatus (or promote OpenStatus) if the public page becomes central. - -**If a friendly point-and-click UI is the priority: Uptime Kuma.** Quickest UI-driven setup and a decent status page, at the cost of no native GitOps. - -**Blackbox Exporter** remains the choice only if (or once) OcotilloAPI adopts Prometheus for broader infrastructure metrics — then fold endpoint monitoring into that stack rather than running a separate tool. It has no status page of its own. - -A reasonable starting move for the stated goal: stand up **OpenStatus** (SaaS free tier to start) monitoring the existing `/health` route, publish a public status page on a custom domain, and commit the monitor definition (YAML/Terraform) to the repo. Keep the probe external to GCP so the page stays up during a platform outage. +- **Want a public status page *and* config-as-code (the stated goal):** → **OpenStatus**. +- **Want managed, all-in-one, free to start, don't care about GitOps:** → **Better Stack**. +- **Want the prettiest public page for the least money:** → **Instatus** (or **UptimeRobot** if cost is the only axis). +- **Care most about versioned internal health checks, public page secondary:** → **Gatus** (pair with a status-page tool later). +- **Want the quickest friendly UI, self-hosted:** → **Uptime Kuma**. +- **Already adopting Prometheus for infra metrics:** → **Blackbox Exporter**, folded into that stack. --- ## Sources -- [Uptime Kuma — GitHub](https://github.com/louislam/uptime-kuma) -- [Uptime Kuma — official site](https://uptimekuma.org/) -- [Uptime Kuma: Self-Hosted Uptime Monitoring for Servers and APIs](https://trivox.sh/blog/content/uptime-kuma-self-hosted-monitoring/) -- [Gatus — GitHub](https://github.com/TwiN/gatus) -- [Gatus: A Complete Guide to Self-Hosted Service Monitoring and Status Pages](https://www.blog.brightcoding.dev/2025/07/26/gatus-a-complete-guide-to-self-hosted-service-monitoring-and-status-pages/) -- [Gatus vs Uptime Kuma: A Detailed Comparison (2026)](https://openalternative.co/compare/gatus/vs/uptime-kuma) -- [Prometheus Blackbox Exporter — GitHub](https://github.com/prometheus/blackbox_exporter) -- [Prometheus Blackbox Exporter: Ultimate Guide (SolarWinds)](https://www.solarwinds.com/blog/prometheus-blackbox-exporter) -- [How to Use Alertmanager and Blackbox Exporter to Monitor Your Web Server (DigitalOcean)](https://www.digitalocean.com/community/tutorials/how-to-use-alertmanager-and-blackbox-exporter-to-monitor-your-web-server-on-ubuntu-16-04) - [OpenStatus — official site](https://www.openstatus.dev/) - [OpenStatus — GitHub](https://github.com/openstatusHQ/openstatus) - [OpenStatus — self-hosting guide](https://docs.openstatus.dev/guides/self-hosting-openstatus/) - [OpenStatus — self-host status page only (lightweight)](https://docs.openstatus.dev/guides/self-host-status-page-only/) +- [OpenStatus — Best Open Source Status Page Tools in 2026](https://www.openstatus.dev/guides/best-opensource-status-page-2026) +- [UptimeRobot — Best Status Page Tools in 2026 (SaaS and open source)](https://uptimerobot.com/knowledge-hub/comparisons-and-alternatives/best-status-page-tools/) +- [Hyperping — Top Statuspage Alternatives (2026)](https://hyperping.com/blog/best-statuspage-alternatives) +- [OneUptime — Best Statuspage Alternatives (2026)](https://oneuptime.com/blog/post/2026-03-10-best-statuspage-alternatives/view) +- [Instatus — Best Self-Hosted Status Pages for 2026](https://instatus.com/blog/best-self-hosted-status-pages) +- [awesome-status-pages (curated list)](https://github.com/ivbeg/awesome-status-pages) +- [Uptime Kuma — GitHub](https://github.com/louislam/uptime-kuma) +- [Gatus — GitHub](https://github.com/TwiN/gatus) +- [Prometheus Blackbox Exporter — GitHub](https://github.com/prometheus/blackbox_exporter) From 8cc9c88ff56a116c8e6d9400ba5f069213ecdaa5 Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 7 Jul 2026 15:12:38 -0600 Subject: [PATCH 135/160] fix(api): probe the database in /health so a 200 proves PostGIS is reachable /health previously returned {"status": "ok", "version": ...} without touching the database, so a 200 only proved the process was up -- an uptime monitor or status page could report green while PostGIS was unreachable. Add a lightweight SELECT 1 ping: on success return db=ok (200); on failure return status=degraded, db=error with a 503 so monitors flag the outage. The route is now sync so the sync SQLAlchemy session runs in the threadpool rather than blocking the event loop. Tests: happy path (200, db=ok, version present) and DB-down via dependency override (503, degraded, db=error). Co-Authored-By: Claude Opus 4.8 --- core/app.py | 27 ++++++++++++++++++++++--- tests/test_health.py | 48 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 tests/test_health.py diff --git a/core/app.py b/core/app.py index 6ee7ad992..d14ccecf3 100644 --- a/core/app.py +++ b/core/app.py @@ -20,13 +20,17 @@ from contextlib import asynccontextmanager from typing import AsyncGenerator -from fastapi import FastAPI +from fastapi import Depends, FastAPI, Response, status from fastapi import Request from fastapi.openapi.docs import ( get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html, ) from fastapi.openapi.utils import get_openapi +from sqlalchemy import text +from sqlalchemy.orm import Session + +from db.engine import get_db_session from .settings import settings @@ -221,8 +225,25 @@ async def warmup(): @app.get("/health", tags=["meta"]) @public_route - async def health(): - return {"status": "ok", "version": settings.version} + 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 + # the "db" field; on failure return 503 so they flag the outage. + try: + session.execute(text("SELECT 1")) + db_ok = True + except Exception: + logger.exception("health check: database ping failed") + db_ok = False + + if not db_ok: + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + + return { + "status": "ok" if db_ok else "degraded", + "db": "ok" if db_ok else "error", + "version": settings.version, + } return app diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 000000000..ef8758d8c --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,48 @@ +# =============================================================================== +# Copyright 2025 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 db.engine import get_db_session +from tests import client + + +def test_health_ok(): + """A healthy service pings the DB and reports db=ok with a 200.""" + response = client.get("/health") + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + assert body["db"] == "ok" + assert "version" in body + + +def test_health_db_down_returns_503(): + """When the DB ping fails, /health returns 503 and reports db=error.""" + + class _BadSession: + def execute(self, *args, **kwargs): + raise Exception("simulated database outage") + + def _bad_session(): + yield _BadSession() + + client.app.dependency_overrides[get_db_session] = _bad_session + try: + response = client.get("/health") + assert response.status_code == 503 + body = response.json() + assert body["status"] == "degraded" + assert body["db"] == "error" + finally: + client.app.dependency_overrides.pop(get_db_session, None) From c4f7dc734c78f10f1f33f44c29d2a55485e313b9 Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 7 Jul 2026 16:23:57 -0600 Subject: [PATCH 136/160] feat(monitoring): add OpenStatus monitoring-as-code config Adds openstatus.yaml at the repo root defining an HTTP monitor on the production /health endpoint (https://ocotillo-api.newmexicowaterdata.org/health) asserting statusCode == 200. Because /health returns 503 when Postgres is unreachable, a 200 proves both the app process and the database are healthy. Config-as-code per the recommendation in docs/api-monitoring-options.md: apply with `openstatus monitors apply --config openstatus.yaml`. Frequency/regions are tuned for the SaaS free tier (10m). Includes a commented db=ok body assertion (enable once the DB-checking /health from PR #773 is live in prod) and a commented staging monitor. Co-Authored-By: Claude Opus 4.8 --- openstatus.yaml | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 openstatus.yaml diff --git a/openstatus.yaml b/openstatus.yaml new file mode 100644 index 000000000..751dd35ab --- /dev/null +++ b/openstatus.yaml @@ -0,0 +1,59 @@ +# yaml-language-server: $schema=https://www.openstatus.dev/schema.json +# +# OpenStatus monitoring-as-code for OcotilloAPI. +# See docs/api-monitoring-options.md for the tool evaluation and rationale. +# +# Apply with the OpenStatus CLI (https://www.openstatus.dev/docs/cli): +# openstatus monitors apply --config openstatus.yaml +# Authenticate first via `openstatus login` (or set the OpenStatus API key per +# the CLI docs). `apply` diffs this file against the account and creates, +# updates, or deletes monitors to match — this file is the source of truth. +# +# frequency/regions are set for the SaaS free tier (10m interval). Raise the +# frequency and add regions once on a paid tier or self-hosted. + +ocotillo-api-production: + name: "OcotilloAPI — Production" + description: "Production OcotilloAPI health endpoint (FastAPI + PostGIS on App Engine)." + frequency: "10m" + active: true + regions: ["iad", "lax"] + retry: 3 + kind: http + request: + url: https://ocotillo-api.newmexicowaterdata.org/health + method: GET + headers: + User-Agent: openstatus + assertions: + # /health returns 503 when Postgres is unreachable (see core/app.py), so a + # 200 proves both the app process AND the database are healthy. + - kind: statusCode + compare: eq + target: 200 + # Extra insurance: assert the JSON "db" field is explicitly ok. Requires the + # DB-check /health (PR #773, > v1.1.5) to be live in production; enable once + # that release has deployed to prod. + # - kind: textBody + # compare: contains + # target: '"db":"ok"' + +# Staging monitor — uncomment to also watch staging (counts against the SaaS +# free-tier one-monitor limit; enable on a paid tier or self-host). +# ocotillo-api-staging: +# name: "OcotilloAPI — Staging" +# description: "Staging OcotilloAPI health endpoint." +# frequency: "10m" +# active: true +# regions: ["iad"] +# retry: 3 +# kind: http +# request: +# url: https://ocotillo-api-staging.newmexicowaterdata.org/health +# method: GET +# headers: +# User-Agent: openstatus +# assertions: +# - kind: statusCode +# compare: eq +# target: 200 From 7802f015134dcf22f436d3f7f6f937babb9caa0b Mon Sep 17 00:00:00 2001 From: jross Date: Tue, 7 Jul 2026 16:29:51 -0600 Subject: [PATCH 137/160] fix(monitoring): use single region for OpenStatus free tier `openstatus monitors apply` failed with "Region 'lax' is not available on your plan". The SaaS free tier allows a single region; drop to ["iad"]. Adding more regions requires a paid tier or self-hosted probes. Co-Authored-By: Claude Opus 4.8 --- openstatus.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openstatus.yaml b/openstatus.yaml index 751dd35ab..405d8a86c 100644 --- a/openstatus.yaml +++ b/openstatus.yaml @@ -17,7 +17,9 @@ ocotillo-api-production: description: "Production OcotilloAPI health endpoint (FastAPI + PostGIS on App Engine)." frequency: "10m" active: true - regions: ["iad", "lax"] + # SaaS free tier allows a single region; add more (e.g. "lax", "ams") on a + # paid tier or self-hosted probes. + regions: ["iad"] retry: 3 kind: http request: From e13c5c3a23f49a93163973f610a7ed59243314df Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 8 Jul 2026 10:08:13 -0600 Subject: [PATCH 138/160] feat(cli): add chemistry LIMS ingestion with Google Drive sync Add `oco water-chemistry` commands to ingest lab LIMS .xlsx workbooks into the legacy NMA chemistry tables (NMA_MajorChemistry / NMA_MinorTraceChemistry), porting the analyte mapping, non-detect handling, and EPA-200.7 / low-bromide dedup from the AMPAPI chemfile.py script. - bulk-upload: parse a LIMS .xlsx and load a single file - sync-drive: on-demand, engineer-triggered ingest of new/changed files from a Google Drive folder, with an ingested-file manifest stored in GCS - append semantics: each distinct lab sample (WCLab_ID) becomes a new sample point using the next letter incrementor on the base PointID (MG-030A, MG-030B, ... Z, AA); a lab sample already recorded for the well is skipped, so re-runs are idempotent - add a `cli` dependency group (openpyxl, google-api-python-client); CI syncs it explicitly, the production requirements export excludes it - add docs/chemistry-ingestion-runbook.md Refs BDMS-1034 Co-Authored-By: Claude Opus 4.8 --- .env.example | 7 + .github/workflows/jira_codex_pr.yml | 2 +- .github/workflows/tests.yml | 4 +- cli/cli.py | 217 ++++++++++ cli/service_adapter.py | 12 + docs/chemistry-ingestion-runbook.md | 212 ++++++++++ pyproject.toml | 8 + services/chemistry_drive.py | 310 ++++++++++++++ services/chemistry_lims.py | 634 ++++++++++++++++++++++++++++ tests/test_chemistry_drive.py | 219 ++++++++++ tests/test_chemistry_lims.py | 287 +++++++++++++ uv.lock | 79 ++++ 12 files changed, 1988 insertions(+), 3 deletions(-) create mode 100644 docs/chemistry-ingestion-runbook.md create mode 100644 services/chemistry_drive.py create mode 100644 services/chemistry_lims.py create mode 100644 tests/test_chemistry_drive.py create mode 100644 tests/test_chemistry_lims.py diff --git a/.env.example b/.env.example index 3b53b9ae7..dfdc98844 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,13 @@ TRANSFER_NMW_MIRROR=True # load the NMW_* 1:1 staging mirror GCS_BUCKET_NAME= GOOGLE_APPLICATION_CREDENTIALS=/path/to/gcs_credentials.json +# chemistry ingestion: Google Drive folder an engineer ingests LIMS .xlsx +# workbooks from, on demand (no polling/scheduling; service account / ADC must +# have read access to this folder). The ingested-file manifest is stored in +# GCS_BUCKET_NAME at CHEMISTRY_INGEST_MANIFEST_PATH. +CHEMISTRY_DRIVE_FOLDER_ID= +CHEMISTRY_INGEST_MANIFEST_PATH=chemistry-ingest/manifest.json + # set to development for lexicon and parameter to be populated and enable the enums to work MODE=development diff --git a/.github/workflows/jira_codex_pr.yml b/.github/workflows/jira_codex_pr.yml index 344177723..110e0ce79 100644 --- a/.github/workflows/jira_codex_pr.yml +++ b/.github/workflows/jira_codex_pr.yml @@ -71,7 +71,7 @@ jobs: - name: Sync dependencies (pyproject/uv.lock) run: | set -euo pipefail - uv sync --all-extras --dev + uv sync --all-extras --dev --group cli - name: Verify tooling exists run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 72f35451e..272b4bcc8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -82,7 +82,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 + run: uv sync --locked --all-extras --dev --group cli - name: Show Alembic heads run: uv run alembic heads @@ -174,7 +174,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 + run: uv sync --locked --all-extras --dev --group cli - name: Show Alembic heads run: uv run alembic heads diff --git a/cli/cli.py b/cli/cli.py index 14c8f9470..8a6432e4f 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -32,8 +32,10 @@ cli = typer.Typer(help="Command line interface for managing the application.") water_levels = typer.Typer(help="Water-level utilities") +water_chemistry = typer.Typer(help="Water-chemistry utilities") data_migrations = typer.Typer(help="Data migration utilities") cli.add_typer(water_levels, name="water-levels") +cli.add_typer(water_chemistry, name="water-chemistry") cli.add_typer(data_migrations, name="data-migrations") @@ -959,6 +961,221 @@ def water_levels_bulk_upload( raise typer.Exit(result.exit_code) +@water_chemistry.command("bulk-upload") +def water_chemistry_bulk_upload( + file_path: str = typer.Option( + ..., + "--file", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="Path to LIMS .xlsx workbook containing chemistry results.", + ), + output_format: OutputFormat | None = typer.Option( + None, + "--output", + help="Optional output format", + ), + theme: ThemeMode = typer.Option( + ThemeMode.auto, "--theme", help="Color theme: auto, light, dark." + ), +): + """ + parse a LIMS chemistry workbook and load it into the NMA Major/Minor + chemistry tables. All-or-nothing: if any analyte already exists, or any row + fails to map, nothing is written. + """ + from cli.service_adapter import chemistry_lims_xlsx + + colors = _palette(theme) + result = chemistry_lims_xlsx( + file_path, pretty_json=output_format == OutputFormat.json + ) + + if output_format == OutputFormat.json: + typer.echo(result.stdout) + raise typer.Exit(result.exit_code) + + payload = result.payload if isinstance(result.payload, dict) else {} + summary = payload.get("summary", {}) + validation_errors = payload.get("validation_errors", []) + created_samples = payload.get("created_samples", []) + skipped_duplicates = payload.get("skipped_duplicates", []) + + if result.exit_code == 0: + typer.secho("[WATER CHEMISTRY IMPORT] SUCCESS", fg=colors["ok"], bold=True) + else: + typer.secho( + "[WATER CHEMISTRY IMPORT] COMPLETED WITH ISSUES", + fg=colors["issue"], + bold=True, + ) + typer.secho("=" * 72, fg=colors["accent"]) + + if summary: + processed = summary.get("total_rows_processed", 0) + imported = summary.get("total_rows_imported", 0) + rows_with_issues = summary.get("validation_errors_or_warnings", 0) + typer.secho("SUMMARY", fg=colors["accent"], bold=True) + label_width = 16 + value_width = 8 + typer.secho(" " + "-" * (label_width + 3 + value_width), fg=colors["muted"]) + typer.secho( + f" {'processed':<{label_width}} | {processed:>{value_width}}", + fg=colors["accent"], + ) + typer.secho( + f" {'imported':<{label_width}} | {imported:>{value_width}}", + fg=colors["ok"], + ) + issue_color = colors["issue"] if rows_with_issues else colors["ok"] + typer.secho( + f" {'rows_with_issues':<{label_width}} | {rows_with_issues:>{value_width}}", + fg=issue_color, + ) + typer.echo() + + if created_samples: + typer.secho("CREATED SAMPLES", fg=colors["ok"], bold=True) + for sample in created_samples: + typer.secho( + f" - {sample['sample_point_id']} " + f"(WCLab_ID {sample.get('wclab_id')}): {sample.get('rows', 0)} row(s)", + fg=colors["ok"], + ) + typer.echo() + + if skipped_duplicates: + typer.secho("SKIPPED (already ingested)", fg=colors["muted"], bold=True) + for dupe in skipped_duplicates: + typer.secho( + f" - {dupe['pointid']} (WCLab_ID {dupe.get('wclab_id')})", + fg=colors["field"], + ) + typer.echo() + + if validation_errors: + typer.secho("VALIDATION", fg=colors["accent"], bold=True) + typer.secho( + f"Validation errors: {len(validation_errors)}", + fg=colors["issue"], + bold=True, + ) + for entry in validation_errors[:25]: + typer.secho(f" - {entry}", fg=colors["issue"]) + if len(validation_errors) > 25: + typer.secho( + f"... and {len(validation_errors) - 25} more validation errors", + fg=colors["issue"], + ) + + typer.secho("=" * 72, fg=colors["accent"]) + raise typer.Exit(result.exit_code) + + +@water_chemistry.command("sync-drive") +def water_chemistry_sync_drive( + folder_id: str = typer.Option( + None, + "--folder-id", + help="Google Drive folder id to scan. Defaults to $CHEMISTRY_DRIVE_FOLDER_ID.", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="List new files without downloading, ingesting, or updating the manifest.", + ), + output_format: OutputFormat | None = typer.Option( + None, + "--output", + help="Optional output format", + ), + theme: ThemeMode = typer.Option( + ThemeMode.auto, "--theme", help="Color theme: auto, light, dark." + ), +): + """ + ingest LIMS chemistry workbooks from a Google Drive folder. Run on demand + by an engineer -- this does no polling or scheduling. New or changed files + are ingested; a manifest of ingested files is kept in GCS so + already-processed files are skipped. + """ + import json as _json + + from services.chemistry_drive import ChemistryDriveConfigError, sync_and_ingest + + colors = _palette(theme) + try: + result = sync_and_ingest(folder_id=folder_id, dry_run=dry_run) + except ChemistryDriveConfigError as exc: + typer.secho(str(exc), fg=colors["issue"], bold=True, err=True) + raise typer.Exit(1) from exc + + if output_format == OutputFormat.json: + typer.echo(_json.dumps(result.to_payload())) + raise typer.Exit(result.exit_code) + + summary = result.to_payload()["summary"] + header = ( + "[CHEMISTRY DRIVE SYNC] DRY RUN" if result.dry_run else "[CHEMISTRY DRIVE SYNC]" + ) + header_color = colors["ok"] if result.exit_code == 0 else colors["issue"] + typer.secho(header, fg=header_color, bold=True) + typer.secho("=" * 72, fg=colors["accent"]) + typer.secho(f"Folder: {result.folder_id}", fg=colors["accent"]) + typer.echo() + + typer.secho("SUMMARY", fg=colors["accent"], bold=True) + for label, value, color in ( + ("files_seen", summary["files_seen"], colors["accent"]), + ("new_files", summary["new_files"], colors["accent"]), + ("ingested", summary["ingested"], colors["ok"]), + ("skipped", summary["skipped"], colors["muted"]), + ( + "failed", + summary["failed"], + colors["issue"] if summary["failed"] else colors["ok"], + ), + ): + typer.secho(f" {label:<12} | {value:>6}", fg=color) + typer.echo() + + if result.dry_run and result.new_files: + typer.secho("NEW FILES (not ingested)", fg=colors["accent"], bold=True) + for name in result.new_files: + typer.secho(f" - {name}", fg=colors["field"]) + typer.echo() + + if result.ingested: + typer.secho("INGESTED", fg=colors["ok"], bold=True) + for record in result.ingested: + typer.secho( + f" - {record['name']}: {record.get('rows_imported', 0)} row(s)", + fg=colors["ok"], + ) + typer.echo() + + if result.failed: + typer.secho("FAILED", fg=colors["issue"], bold=True) + for record in result.failed: + detail = record.get("error") + if not detail: + payload = record.get("payload", {}) + errors = payload.get("validation_errors") or [] + if errors: + detail = errors[0] + if len(errors) > 1: + detail += f" (+{len(errors) - 1} more)" + else: + detail = "ingestion aborted" + typer.secho(f" - {record['name']}: {detail}", fg=colors["issue"]) + typer.echo() + + typer.secho("=" * 72, fg=colors["accent"]) + raise typer.Exit(result.exit_code) + + @data_migrations.command("list") def data_migrations_list( theme: ThemeMode = typer.Option( diff --git a/cli/service_adapter.py b/cli/service_adapter.py index bc7bb6ccf..9b7c4393e 100644 --- a/cli/service_adapter.py +++ b/cli/service_adapter.py @@ -89,6 +89,18 @@ def water_levels_csv(source_file: Path | str, *, pretty_json: bool = False): return result +def chemistry_lims_xlsx(source_file: Path | str, *, pretty_json: bool = False): + from services.chemistry_lims import bulk_upload_chemistry + + if isinstance(source_file, str): + source_file = Path(source_file) + + result = bulk_upload_chemistry(source_file, pretty_json=pretty_json) + if result.stderr: + print(result.stderr, file=sys.stderr) + return result + + def associate_assets(source_directory: Path | str) -> list[str]: """ given a directory diff --git a/docs/chemistry-ingestion-runbook.md b/docs/chemistry-ingestion-runbook.md new file mode 100644 index 000000000..d978edc95 --- /dev/null +++ b/docs/chemistry-ingestion-runbook.md @@ -0,0 +1,212 @@ +# Chemistry Ingestion — Interim Workaround Runbook + +Purpose: capture the known gaps, assumptions, and step-by-step process for +Data Services (Ocotillo) chemistry ingestion, so the manual workaround can be +run consistently while the fuller solution is pursued. + +- Jira: [BDMS-1034](https://nmbgmr.atlassian.net/browse/BDMS-1034). +- Code (Data Services path): + - `services/chemistry_lims.py` — parse a LIMS `.xlsx` and load the legacy + NMA chemistry tables (analyte mapping ported from AMPAPI `chemfile.py`). + - `services/chemistry_drive.py` — on-demand ingest of new files from the + shared Drive folder; updates the manifest. Engineer-triggered; no polling. + - `cli/cli.py` — `oco water-chemistry bulk-upload` and `oco water-chemistry + sync-drive`. +- Target tables: `NMA_MajorChemistry`, `NMA_MinorTraceChemistry`, + `NMA_Chemistry_SampleInfo` (`db/nma_legacy.py`). +- Legacy source: AMPAPI `chemfile.py` (`MajorChemistry` / + `MinorandTraceChemistry` in SQL Server). + +--- + +## 0. The workaround at a glance + +```mermaid +flowchart LR + S["Sianin
    export lab LIMS batch as .xlsx"] --> D["Shared Google Drive folder
    CHEMISTRY_DRIVE_FOLDER_ID"] + E["Engineer
    oco water-chemistry sync-drive (on demand)"] --> D + E --> T["Ingest new/changed files →
    Ocotillo NMA_* chemistry tables"] + E --> M["manifest.json in GCS
    updated with per-file results"] +``` + +Chemistry is ingested into a single destination: the Ocotillo (Data Services) +Postgres database — the legacy `NMA_MajorChemistry`, `NMA_MinorTraceChemistry`, +and `NMA_Chemistry_SampleInfo` tables. + +--- + +## 1. Roles + +- **Sianin** — exports each lab chemistry batch from the LIMS as an `.xlsx` + workbook and drops it in the shared Drive folder. One workbook per batch. +- **Engineer** (e.g. Kelsey) — explicitly triggers the Data Services ingestion + CLI against the folder when a run is wanted, reviews the printed results, and + confirms the manifest updated. Acts on any files reported as `failed`. There + is no polling or scheduling — ingestion only happens when an engineer runs it. +- **Engineering** — owns the CLI, the analyte map, and the fuller solution. + +--- + +## 2. Prerequisites (Kelsey's machine) + +- [ ] Repo checked out; env installed **with the CLI group**: + `uv sync --locked --group cli` (installs `openpyxl` + + `google-api-python-client`, which are not part of the API runtime). +- [ ] Postgres (Ocotillo) reachable; `.env` has `POSTGRES_*` (or Cloud SQL) + creds pointing at the target Data Services database. +- [ ] `GCS_BUCKET_NAME` set (holds the manifest). +- [ ] `CHEMISTRY_DRIVE_FOLDER_ID` set to the shared folder id + (see `.env.example`). Optional `CHEMISTRY_INGEST_MANIFEST_PATH` + (default `chemistry-ingest/manifest.json`). +- [ ] Google credentials available with: + - **read** access to the shared Drive folder, + - **read/write** on the GCS bucket. + Locally this is application-default credentials + (`gcloud auth application-default login`) for an account added to the + folder; in production it is the base64 `GCS_SERVICE_ACCOUNT_KEY` service + account (which must be a member of the folder). + +--- + +## 3. Process — Sianin (drop files) + +1. Export the lab batch from the LIMS as an `.xlsx` workbook. It must carry the + standard LIMS columns: `Param`, `Results_Units`, `Dilution`, `AnalysisTime`, + `SampleNumber`, `CustomerSampleNumber`, `SamplePointID`, `Method`, `Test`, + `ReportedND`, `LowerLimit`, `SampleDate`. +2. Ensure `SamplePointID` matches the well's PointID / Ocotillo Thing name. +3. Drop the workbook in the shared Drive folder. Do not edit a file in place + after it has been ingested — a content change re-ingests it (by md5). + +--- + +## 4. Process — Engineer (run the ingest) + +Dry run first to see what is new without writing anything: + +```bash +oco water-chemistry sync-drive --dry-run +``` + +Then ingest: + +```bash +oco water-chemistry sync-drive +# or point at a specific folder: +oco water-chemistry sync-drive --folder-id +# machine-readable: +oco water-chemistry sync-drive --output json +``` + +Read the summary. Buckets: + +- **ingested** — file loaded; shows rows imported. +- **skipped** — the whole file was already ingested (manifest has a `success` + entry and the file's md5 is unchanged). +- **ingested with skipped samples** — a file loads, but any lab sample + (`WCLab_ID` / SampleNumber) already recorded for the well is skipped and + listed under `skipped_duplicates`; this is normal and not a failure. New lab + samples for the same well are appended as a new lettered sample point + (`MG-030A`, `MG-030B`, ...). +- **failed** — nothing loaded for that file (a data-quality abort). Causes: + +| Reported cause | Meaning | Action | +|----------------|---------|--------| +| `Unmapped analyte Param=...` | A LIMS `Param` name is not in the analyte map. | Send the Param name to engineering to add to `FMapper`. | +| `no matching Thing (well) found` | `SamplePointID` has no Ocotillo well. | Verify the PointID; ensure the well was transferred to Data Services first. | + +Exit code is non-zero if any file failed. + +A single file (bypassing Drive) can be loaded directly: + +```bash +oco water-chemistry bulk-upload --file /path/to/batch.xlsx +``` + +--- + +## 5. The manifest + +- Location: `gs://$GCS_BUCKET_NAME/` + (default `chemistry-ingest/manifest.json`). +- Keyed by **Drive file id**. Each entry records: + `name`, `md5`, `modified_time`, `status` (`success` / `failed`), + `rows_imported`, `validation_errors_or_warnings`, `ingested_at` + (and `error` for hard failures). +- Semantics: + - A file is **skipped** only when its manifest entry is `success` **and** the + Drive md5 is unchanged. + - **failed** or **content-changed** files are retried on the next run. +- The manifest is rewritten after **every** file, so an interrupted run keeps + its progress. +- Inspect it: `gsutil cat gs://$GCS_BUCKET_NAME/chemistry-ingest/manifest.json`. + +--- + +## 6. What the ingest does (summary) + +For each workbook: map each `Param` to an analyte code + target table (major vs +minor) via `FMapper`; compute the value (non-detects become +`LowerLimit × Dilution` with a `<` symbol); collapse duplicate +(SamplePointID, WCLab_ID, analyte) rows (prefer EPA 200.7, or "low bromide" for +Br); resolve the base `SamplePointID → Thing`. Then, per distinct lab sample +(`WCLab_ID`): if that lab sample is already recorded for the well, skip it; +otherwise create a new `NMA_Chemistry_SampleInfo` whose `nma_sample_point_id` +is the base PointID with the **next letter incrementor** appended +(`A`, `B`, ... `Z`, `AA`, ...), and insert the analyte rows under it. +A data-quality problem (a row that fails to map, or a `SamplePointID` with no +matching well) aborts the whole file — nothing is written. + +--- + +## 7. Known gaps + +- **Duplicate detection is WCLab_ID-only.** A re-ingest is recognized by the lab + `WCLab_ID` (SampleNumber). A genuinely new lab sample with a reused SampleNumber + would be treated as a duplicate and skipped; a re-run of the same sample under a + new SampleNumber would append a spurious extra lettered sample. +- **`.xlsx` only.** Legacy `.xls` LIMS exports are not read; the file must be + a modern `.xlsx`. +- **Fixed analyte map.** Unknown `Param` names fail until engineering adds them + to `FMapper`. Only major + minor analytes are handled — field parameters and + radionuclides are out of scope. +- **Well must exist first.** `SamplePointID` must already match an Ocotillo + `Thing.name`; otherwise the file fails. +- **Failed files retry loudly.** A file that fails (e.g. unmapped analyte or a + missing well) is retried on every run and keeps reporting `failed` until + resolved. +- **Concurrent runs race the manifest.** Ingestion is engineer-triggered on + demand by design (no polling/scheduling). But two engineers running + `sync-drive` at the same time race the manifest object (last write wins) — + coordinate so only one run is in flight. +- **No alerting.** Failures surface only in the CLI output the engineer reads. +- **Prod excludes the CLI deps.** The `cli` dependency group + (`openpyxl`, `google-api-python-client`) is not in the production requirements + export, so the ingest runs from an engineer's machine, not the deployed app. + +--- + +## 8. Assumptions + +- One lab batch per `.xlsx`, with the standard LIMS column set (section 3). +- `SamplePointID` == the well PointID == the Ocotillo `Thing.name`. +- All files in the shared folder are chemistry LIMS workbooks (the sync filters + to `.xlsx` by MIME type). +- Analyses agency is NMBGMR; non-detects and units follow the AMPAPI + `chemfile.py` conventions. +- The account running the CLI can read the Drive folder, read/write the GCS + bucket, and reach the Data Services database. + +--- + +## 9. Toward the fuller solution + +Candidate improvements, roughly in priority order: + +- **Stronger duplicate detection** than WCLab_ID alone (e.g. also compare + collection date / analyte set) so a reused or missing SampleNumber can't cause + a wrong skip or a spurious appended sample. +- **Alerting** on failed files (email/Slack) rather than relying on reading CLI + output. +- Make the **analyte map** data-driven (lexicon-backed) so new params don't + require a code change. diff --git a/pyproject.toml b/pyproject.toml index 11ff4d3f7..7759f710e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,6 +143,14 @@ dev = [ "python-dotenv>=1.1.1", "requests>=2.34.2", ] +# CLI-only dependencies (chemistry ingestion / Google Drive sync). These are +# imported lazily by `oco` commands and are not needed by the API runtime, so +# they are excluded from the production requirements export (`uv export +# --no-dev`). CI installs them explicitly with `uv sync --group cli`. +cli = [ + "openpyxl==3.1.5", + "google-api-python-client==2.184.0", +] [tool.pytest.ini_options] filterwarnings = [ diff --git a/services/chemistry_drive.py b/services/chemistry_drive.py new file mode 100644 index 000000000..b6f5f2ea8 --- /dev/null +++ b/services/chemistry_drive.py @@ -0,0 +1,310 @@ +# =============================================================================== +# 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. +# =============================================================================== +""" +Ingest LIMS chemistry workbooks from a Google Drive folder, on demand. + +This is explicitly engineer-triggered: it runs once per invocation and does no +polling or scheduling. New/changed ``.xlsx`` files under +``CHEMISTRY_DRIVE_FOLDER_ID`` are downloaded and handed to +:func:`services.chemistry_lims.bulk_upload_chemistry`. A manifest of +already-ingested files is kept as a JSON object in the GCS bucket +(``CHEMISTRY_INGEST_MANIFEST_PATH``, default ``chemistry-ingest/manifest.json``) +so re-runs only process files that are new or whose contents changed. + +Configuration (environment variables): +* ``CHEMISTRY_DRIVE_FOLDER_ID`` - Drive folder id to scan (shared-drive or + My-Drive folder shared with the service account). +* ``CHEMISTRY_INGEST_MANIFEST_PATH`` - GCS object key for the manifest. +* ``GCS_BUCKET_NAME`` - bucket that holds the manifest (shared with gcs_helper). + +Authentication mirrors ``services.gcs_helper``: in production the base64 +service-account key in ``GCS_SERVICE_ACCOUNT_KEY`` is used (with a Drive scope); +otherwise application-default credentials are used. The service account must be +granted at least read access to the target Drive folder. +""" + +from __future__ import annotations + +import base64 +import io +import json +import logging +import os +from dataclasses import dataclass, field +from datetime import datetime, timezone +from functools import lru_cache +from typing import Any + +from core.settings import settings +from services.chemistry_lims import bulk_upload_chemistry +from services.gcs_helper import get_storage_bucket + +logger = logging.getLogger(__name__) + +DRIVE_SCOPES = ["https://www.googleapis.com/auth/drive.readonly"] +XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +DEFAULT_MANIFEST_PATH = "chemistry-ingest/manifest.json" + + +class ChemistryDriveConfigError(Exception): + """The Drive folder is not configured (missing folder id).""" + + +# --- Google Drive access ------------------------------------------------------- + + +def _drive_credentials(): + from google.oauth2 import service_account + + if settings.mode == "production": + key_base64 = os.environ.get("GCS_SERVICE_ACCOUNT_KEY") + if not key_base64: + raise ChemistryDriveConfigError( + "GCS_SERVICE_ACCOUNT_KEY is required for Drive access in production." + ) + decoded = base64.b64decode(key_base64).decode("utf-8") + return service_account.Credentials.from_service_account_info( + json.loads(decoded), scopes=DRIVE_SCOPES + ) + + import google.auth + + creds, _ = google.auth.default(scopes=DRIVE_SCOPES) + return creds + + +@lru_cache(maxsize=1) +def get_drive_service(): + from googleapiclient.discovery import build + + return build("drive", "v3", credentials=_drive_credentials(), cache_discovery=False) + + +def list_drive_xlsx(folder_id: str, service=None) -> list[dict]: + """List non-trashed ``.xlsx`` files directly under ``folder_id``.""" + service = service or get_drive_service() + query = ( + f"'{folder_id}' in parents " + "and trashed = false " + f"and mimeType = '{XLSX_MIME}'" + ) + files: list[dict] = [] + page_token = None + while True: + response = ( + service.files() + .list( + q=query, + spaces="drive", + corpora="allDrives", + includeItemsFromAllDrives=True, + supportsAllDrives=True, + fields="nextPageToken, files(id, name, md5Checksum, modifiedTime, size)", + pageToken=page_token, + pageSize=100, + ) + .execute() + ) + files.extend(response.get("files", [])) + page_token = response.get("nextPageToken") + if not page_token: + break + return files + + +def download_drive_file(file_id: str, service=None) -> bytes: + """Download a Drive file's bytes.""" + from googleapiclient.http import MediaIoBaseDownload + + service = service or get_drive_service() + request = service.files().get_media(fileId=file_id, supportsAllDrives=True) + buffer = io.BytesIO() + downloader = MediaIoBaseDownload(buffer, request) + done = False + while not done: + _status, done = downloader.next_chunk() + return buffer.getvalue() + + +# --- manifest (GCS JSON object) ------------------------------------------------ + + +def _manifest_path() -> str: + return os.environ.get("CHEMISTRY_INGEST_MANIFEST_PATH", DEFAULT_MANIFEST_PATH) + + +def load_manifest(bucket=None) -> dict[str, dict]: + bucket = bucket or get_storage_bucket() + blob = bucket.blob(_manifest_path()) + if not blob.exists(): + return {} + try: + return json.loads(blob.download_as_text()) + except (ValueError, json.JSONDecodeError): + logger.warning("Chemistry ingest manifest is corrupt; starting fresh.") + return {} + + +def save_manifest(manifest: dict[str, dict], bucket=None) -> None: + bucket = bucket or get_storage_bucket() + blob = bucket.blob(_manifest_path()) + blob.upload_from_string( + json.dumps(manifest, indent=2, sort_keys=True), + content_type="application/json", + ) + + +# --- orchestration ------------------------------------------------------------- + + +@dataclass +class DriveSyncResult: + folder_id: str + files_seen: int = 0 + new_files: list[str] = field(default_factory=list) + ingested: list[dict] = field(default_factory=list) + skipped: list[str] = field(default_factory=list) + failed: list[dict] = field(default_factory=list) + dry_run: bool = False + + @property + def exit_code(self) -> int: + return 1 if self.failed else 0 + + def to_payload(self) -> dict[str, Any]: + return { + "folder_id": self.folder_id, + "dry_run": self.dry_run, + "summary": { + "files_seen": self.files_seen, + "new_files": len(self.new_files), + "ingested": len(self.ingested), + "skipped": len(self.skipped), + "failed": len(self.failed), + }, + "new_files": self.new_files, + "ingested": self.ingested, + "skipped": self.skipped, + "failed": self.failed, + } + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def sync_and_ingest( + folder_id: str | None = None, + *, + dry_run: bool = False, + bucket=None, + drive_service=None, +) -> DriveSyncResult: + """Scan the Drive folder and ingest any new or changed workbooks. + + A file is considered already ingested when the manifest holds a + ``success`` entry whose ``md5`` matches the current Drive checksum. Failed + or changed files are retried on the next run. + """ + folder_id = folder_id or os.environ.get("CHEMISTRY_DRIVE_FOLDER_ID") + if not folder_id: + raise ChemistryDriveConfigError( + "No Drive folder configured. Set CHEMISTRY_DRIVE_FOLDER_ID or pass --folder-id." + ) + + bucket = bucket or get_storage_bucket() + manifest = load_manifest(bucket) + files = list_drive_xlsx(folder_id, service=drive_service) + + result = DriveSyncResult( + folder_id=folder_id, files_seen=len(files), dry_run=dry_run + ) + + for meta in files: + file_id = meta["id"] + name = meta.get("name", file_id) + md5 = meta.get("md5Checksum") + + entry = manifest.get(file_id) + already_ingested = ( + entry is not None + and entry.get("status") == "success" + and entry.get("md5") == md5 + ) + if already_ingested: + result.skipped.append(name) + continue + + result.new_files.append(name) + if dry_run: + continue + + logger.info("Ingesting chemistry workbook from Drive: %s (%s)", name, file_id) + try: + data = download_drive_file(file_id, service=drive_service) + upload = bulk_upload_chemistry(data) + except Exception as exc: # network / parse / DB failure for this file + logger.exception("Failed to ingest Drive file %s (%s)", name, file_id) + record = { + "name": name, + "file_id": file_id, + "status": "failed", + "error": str(exc), + } + manifest[file_id] = { + "name": name, + "md5": md5, + "modified_time": meta.get("modifiedTime"), + "status": "failed", + "error": str(exc), + "ingested_at": _now_iso(), + } + save_manifest(manifest, bucket) + result.failed.append(record) + continue + + summary = upload.payload.get("summary", {}) + status = "success" if upload.exit_code == 0 else "failed" + manifest[file_id] = { + "name": name, + "md5": md5, + "modified_time": meta.get("modifiedTime"), + "status": status, + "rows_imported": summary.get("total_rows_imported", 0), + "validation_errors_or_warnings": summary.get( + "validation_errors_or_warnings", 0 + ), + "ingested_at": _now_iso(), + } + # Persist after every file so an interrupted run keeps its progress. + save_manifest(manifest, bucket) + + record = { + "name": name, + "file_id": file_id, + "status": status, + "rows_imported": summary.get("total_rows_imported", 0), + "payload": upload.payload, + } + if status == "success": + result.ingested.append(record) + else: + result.failed.append(record) + + return result + + +# ============= EOF ============================================= diff --git a/services/chemistry_lims.py b/services/chemistry_lims.py new file mode 100644 index 000000000..25bb6f553 --- /dev/null +++ b/services/chemistry_lims.py @@ -0,0 +1,634 @@ +# =============================================================================== +# 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. +# =============================================================================== +""" +Ingest a laboratory LIMS chemistry workbook into the legacy NMA chemistry +tables (NMA_MajorChemistry / NMA_MinorTraceChemistry). + +Ported from the AMPAPI ``chemfile.py`` ingestion script. The AMPAPI original +read an ``.xls`` LIMS export with ``xlrd`` and inserted rows into the SQL Server +``MajorChemistry`` / ``MinorandTraceChemistry`` tables keyed off the +``Chemistry SampleInfo`` table. This adaptation: + +* reads an ``.xlsx`` workbook with ``openpyxl``, +* maps each LIMS ``Param`` to an analyte code + target table via ``FMapper``, +* resolves each ``SamplePointID`` (the base well PointID) to a ``Thing`` by + name, +* appends each distinct lab sample (``WCLab_ID``) as a new + ``NMA_Chemistry_SampleInfo`` row whose ``nma_sample_point_id`` is the base + PointID with the next letter incrementor appended (``A``, ``B``, ... ``Z``, + ``AA``, ...), skipping a lab sample already recorded for that well. + +The public entrypoint is :func:`bulk_upload_chemistry`. +""" + +from __future__ import annotations + +import io +import json +import re +import uuid +from dataclasses import dataclass +from datetime import date, datetime +from itertools import groupby +from pathlib import Path +from typing import Any, BinaryIO + +from openpyxl import load_workbook +from sqlalchemy import select +from sqlalchemy.orm import Session + +from db import ( + NMA_Chemistry_SampleInfo, + NMA_MajorChemistry, + NMA_MinorTraceChemistry, + Thing, +) +from db.engine import session_ctx + +# --- analyte mapping (ported verbatim from AMPAPI chemfile.py) ----------------- + +MINOR = "MinorandTraceChemistry" +MAJOR = "MajorChemistry" +EPM = "epm" +MGL = "mg/L" +PDIFF = "%Diff" +PH = "pH" +COND = "µS/cm" + +ANALYSES_AGENCY = "NMBGMR" + + +class AnalyteField: + def __init__(self, xlsfield, dbanalyte, table, units=None, method=None): + self.xlsfield = xlsfield + self.dbanalyte = dbanalyte + self.table = table + self.units = units + self.method = method + + +class FMapper: + def __init__(self): + self._map = [ + AnalyteField("alkalinity as caco3", "ALK", MAJOR, method="As CaCO3"), + AnalyteField("aluminum", "Al", MINOR), + AnalyteField("anions total", "TAn", MAJOR, EPM), + AnalyteField("antimony 121", "Sb", MINOR), + AnalyteField("antimony 123", "Sb", MINOR), + AnalyteField("antimony", "Sb", MINOR), + AnalyteField("arsenic", "As", MINOR), + AnalyteField("barium", "Ba", MINOR), + AnalyteField("beryllium", "Be", MINOR), + AnalyteField( + "bicarbonate (hco3)", "HCO3", MAJOR, method="Alkalinity as HC03" + ), + AnalyteField("boron 11", "B", MINOR), + AnalyteField("boron", "B", MINOR), + AnalyteField("bromide", "Br", MINOR), + AnalyteField("cadmium 111", "Cd", MINOR), + AnalyteField("cadmium", "Cd", MINOR), + AnalyteField("calcium", "Ca", MAJOR), + AnalyteField("carbonate (co3)", "CO3", MAJOR), + AnalyteField("cations total", "TCat", MAJOR, EPM), + AnalyteField("chloride", "Cl", MAJOR), + AnalyteField("chromium", "Cr", MINOR), + AnalyteField("cobalt", "Co", MINOR), + AnalyteField("copper 65", "Cu", MINOR), + AnalyteField("copper", "Cu", MINOR), + AnalyteField("fluoride", "F", MINOR), + AnalyteField("hardness", "HRD", MAJOR, MGL, method="As CaCO3"), + AnalyteField("iron", "Fe", MINOR), + AnalyteField("lead", "Pb", MINOR), + AnalyteField("lithium", "Li", MINOR), + AnalyteField("magnesium", "Mg", MAJOR), + AnalyteField("manganese", "Mn", MINOR), + AnalyteField("mercury", "Hg", MINOR), + AnalyteField("molybdenum 95", "Mo", MINOR), + AnalyteField("molybdenum", "Mo", MINOR), + AnalyteField("nickel", "Ni", MINOR), + AnalyteField("nitrate", "NO3", MINOR), + AnalyteField("nitrite", "NO2", MINOR), + AnalyteField("phosphate", "PO4", MINOR), + AnalyteField("percent difference", "IONBAL", MAJOR, PDIFF), + AnalyteField("potassium", "K", MAJOR), + AnalyteField("selenium", "Se", MINOR), + AnalyteField("siliconDioxide", "SiO2", MINOR), + AnalyteField("sio2", "SiO2", MINOR), + AnalyteField("silicon", "Si", MINOR), + AnalyteField("silver 107", "Ag", MINOR), + AnalyteField("silver", "Ag", MINOR), + AnalyteField("sodium", "Na", MAJOR), + AnalyteField("specific conductance", "CONDLAB", MAJOR, COND), + AnalyteField("strontium", "Sr", MINOR), + AnalyteField("sulfate", "SO4", MAJOR), + AnalyteField("tds calc", "TDS", MAJOR, method="Calculation"), + AnalyteField("thallium", "Tl", MINOR), + AnalyteField("thorium", "Th", MINOR), + AnalyteField("tin", "Sn", MINOR), + AnalyteField("titanium", "Ti", MINOR), + AnalyteField("uranium", "U", MINOR), + AnalyteField("vanadium", "V", MINOR), + AnalyteField("zinc 66", "Zn", MINOR), + AnalyteField("zinc", "Zn", MINOR), + AnalyteField("pH", "pHL", MAJOR, PH), + AnalyteField("ortho phosphate", "PO4", MINOR), + ] + + def values(self): + return self._map + + def get(self, key, attr="xlsfield"): + if key is None: + return None + for p in self._map: + value = getattr(p, attr) + if not isinstance(value, (list, tuple)): + value = (value,) + for vi in value: + if str(vi).lower() == str(key).lower(): + return p + return None + + +FM = FMapper() + +# Target ORM model per FMapper table bucket. +_TABLE_MODEL = {MAJOR: NMA_MajorChemistry, MINOR: NMA_MinorTraceChemistry} + + +class ChemistryMappingError(Exception): + """A LIMS row could not be normalized into an analyte measurement.""" + + +@dataclass +class ChemistryUploadResult: + exit_code: int + stdout: str + stderr: str + payload: dict[str, Any] + + +# --- workbook parsing ---------------------------------------------------------- + +# Columns the LIMS export is expected to carry. Extra columns are ignored; +# missing columns simply read back as ``None``. +LIMS_COLUMNS = ( + "Param", + "Results_Units", + "Dilution", + "AnalysisTime", + "SampleNumber", + "CustomerSampleNumber", + "SamplePointID", + "Method", + "Test", + "ReportedND", + "LowerLimit", + "SampleDate", +) + + +def read_lims_xlsx( + source: Path | str | bytes | BinaryIO, sheet_index: int = 0 +) -> list[dict]: + """Read a LIMS ``.xlsx`` workbook into a list of header->value dicts.""" + if isinstance(source, (bytes, bytearray)): + handle: Any = io.BytesIO(source) + elif isinstance(source, (str, Path)): + handle = source + else: + handle = source + + wb = load_workbook(filename=handle, read_only=True, data_only=True) + try: + sheet = wb.worksheets[sheet_index] + rows = sheet.iter_rows(values_only=True) + try: + header = [str(h).strip() if h is not None else "" for h in next(rows)] + except StopIteration: + return [] + records = [] + for row in rows: + if all(v is None for v in row): + continue + records.append(dict(zip(header, row))) + return records + finally: + wb.close() + + +# --- record normalization ------------------------------------------------------ + + +def _get(record: dict, key: str) -> Any: + value = record.get(key) + if isinstance(value, str): + value = value.strip() + if value == "": + return None + return value + + +def _to_float(value: Any) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _to_datetime(value: Any) -> datetime | None: + if value is None or value == "": + return None + if isinstance(value, datetime): + return value + if isinstance(value, date): + return datetime(value.year, value.month, value.day) + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%m/%d/%Y", "%m/%d/%Y %H:%M:%S"): + try: + return datetime.strptime(str(value), fmt) + except ValueError: + continue + return None + + +def prep_record(record: dict) -> dict: + """Normalize one raw LIMS row into an analyte-measurement dict. + + Raises :class:`ChemistryMappingError` when the row cannot be mapped. + """ + param = _get(record, "Param") + pm = FM.get(param) + if pm is None: + raise ChemistryMappingError(f"Unmapped analyte Param={param!r}") + + pointid = _get(record, "SamplePointID") or _get(record, "CustomerSampleNumber") + if not pointid: + raise ChemistryMappingError("Missing SamplePointID") + + units = pm.units or _get(record, "Results_Units") + + reported = _get(record, "ReportedND") + if reported is not None and str(reported).upper() == "ND": + lower = _to_float(_get(record, "LowerLimit")) or 0.0 + dilution = _to_float(_get(record, "Dilution")) + dilution = dilution if dilution else 1.0 + sample_value = lower * dilution + symbol = "<" + else: + sample_value = _to_float(reported) + symbol = None + + analysis_method = _get(record, "Method") + if pm.method: + analysis_method = ( + f"{analysis_method}, {pm.method}" if analysis_method else pm.method + ) + + analysis_date = _to_datetime(_get(record, "AnalysisTime")) + sample_date = _to_datetime(_get(record, "SampleDate")) or analysis_date + wclab_id = _get(record, "SampleNumber") + + return { + "analyte": pm.dbanalyte, + "table": pm.table, + "units": str(units) if units is not None else None, + "symbol": symbol, + "sample_value": sample_value, + "analysis_method": str(analysis_method) if analysis_method else None, + "analysis_date": analysis_date, + "sample_date": sample_date, + "wclab_id": str(wclab_id) if wclab_id is not None else None, + "samplepointid": str(pointid), + "test": _get(record, "Test"), + } + + +def dedupe_records(records: list[dict]) -> list[dict]: + """Collapse duplicate (SamplePointID, WCLab_ID, Analyte) rows. + + Mirrors AMPAPI chemfile.dbprep_records: when the same analyte is reported + more than once for the *same lab sample*, keep the ``low bromide`` test for + Br and the ``EPA 200.7`` method for everything else. Falls back to the first + row when no preferred method is present. Keyed on ``WCLab_ID`` too so two + distinct lab samples for one well keep their own analyte values. + """ + + def keyf(r: dict) -> tuple[str, str, str]: + return (r["samplepointid"], r["wclab_id"] or "", r["analyte"]) + + out: list[dict] = [] + for (_pid, _wclab, analyte), group in groupby(sorted(records, key=keyf), key=keyf): + group = list(group) + if len(group) < 2: + out.extend(group) + continue + + if analyte == "Br": + picked = next( + (r for r in group if (r.get("test") or "").casefold() == "low bromide"), + None, + ) + else: + picked = next( + ( + r + for r in group + if (r.get("analysis_method") or "") + .casefold() + .startswith("epa 200.7") + ), + None, + ) + out.append(picked or group[0]) + return out + + +# --- persistence --------------------------------------------------------------- + + +_SUFFIX_RE_TEMPLATE = r"^{base}([A-Z]+)$" + + +def _resolve_thing_id(session: Session, pointid: str) -> int | None: + things = session.scalars(select(Thing).where(Thing.name == pointid)).all() + if not things: + return None + # Thing.name is not guaranteed unique; take the lowest id deterministically. + return min(t.id for t in things) + + +def _suffix_to_int(suffix: str) -> int: + """Bijective base-26: A->1, B->2, ..., Z->26, AA->27, AB->28, ...""" + n = 0 + for ch in suffix: + n = n * 26 + (ord(ch) - ord("A") + 1) + return n + + +def _int_to_suffix(n: int) -> str: + """Inverse of :func:`_suffix_to_int` (``n`` >= 1).""" + letters: list[str] = [] + while n > 0: + n, rem = divmod(n - 1, 26) + letters.append(chr(ord("A") + rem)) + return "".join(reversed(letters)) + + +def _existing_suffix_ints(session: Session, thing_id: int, base: str) -> set[int]: + """Suffix numbers already used for ``base`` under this Thing. + + Chemistry sample points are the well PointID (``base``) with an appended + letter incrementor (``A``, ``B``, ... ``Z``, ``AA``, ...). Returns the set + of used incrementors, as bijective-base-26 integers, so the next one can be + computed. + """ + values = session.scalars( + select(NMA_Chemistry_SampleInfo.nma_sample_point_id).where( + NMA_Chemistry_SampleInfo.thing_id == thing_id + ) + ).all() + pattern = re.compile(_SUFFIX_RE_TEMPLATE.format(base=re.escape(base))) + used: set[int] = set() + for value in values: + if not value: + continue + match = pattern.match(value) + if match: + used.add(_suffix_to_int(match.group(1))) + return used + + +def _sample_exists_for_wclab( + session: Session, thing_id: int, wclab_id: str | None +) -> bool: + """True if this lab sample (WCLab_ID) is already recorded for the Thing.""" + if wclab_id is None: + return False + return ( + session.scalars( + select(NMA_Chemistry_SampleInfo.id).where( + NMA_Chemistry_SampleInfo.thing_id == thing_id, + NMA_Chemistry_SampleInfo.nma_wclab_id == wclab_id, + ) + ).first() + is not None + ) + + +def _build_measurement( + model, chemistry_sample_info_id: int, rec: dict, sample_point_id: str +): + analysis_date = rec["analysis_date"] + if model is NMA_MinorTraceChemistry and isinstance(analysis_date, datetime): + # NMA_MinorTraceChemistry.analysis_date is a DATE column. + analysis_date = analysis_date.date() + return model( + chemistry_sample_info_id=chemistry_sample_info_id, + nma_global_id=uuid.uuid4(), + nma_sample_point_id=sample_point_id, + nma_wclab_id=rec["wclab_id"], + analyte=rec["analyte"], + symbol=rec["symbol"], + sample_value=rec["sample_value"], + units=rec["units"], + analysis_method=rec["analysis_method"], + analysis_date=analysis_date, + analyses_agency=ANALYSES_AGENCY, + ) + + +def bulk_upload_chemistry( + source: Path | str | bytes, *, pretty_json: bool = False +) -> ChemistryUploadResult: + """Ingest a LIMS ``.xlsx`` workbook into the NMA chemistry tables. + + ``source`` may be a filesystem path or the raw ``.xlsx`` bytes (e.g. a file + downloaded from Google Drive). + + The workbook's ``SamplePointID`` is the base well PointID. Each distinct lab + sample (``WCLab_ID`` / SampleNumber) for a well becomes a new + ``NMA_Chemistry_SampleInfo`` row whose ``nma_sample_point_id`` is the base + with the next letter incrementor appended (``A``, ``B``, ... ``Z``, ``AA``, + ...). A lab sample already recorded for the well (same ``WCLab_ID``) is + skipped, so re-running is idempotent. + + A data-quality problem (a row that fails to map, or a ``SamplePointID`` with + no matching Thing) aborts the whole file -- nothing is written. + """ + if isinstance(source, str): + source = Path(source) + + try: + raw_records = read_lims_xlsx(source) + except Exception as exc: # openpyxl raises a variety of parse errors + return _result( + processed=0, + imported=0, + validation_errors=[f"Could not read workbook: {exc}"], + skipped_duplicates=[], + created=[], + pretty_json=pretty_json, + ) + + processed = len(raw_records) + validation_errors: list[str] = [] + prepped: list[dict] = [] + for offset, raw in enumerate(raw_records): + # +2: worksheet row 1 is the header, enumerate is 0-based. + row_number = offset + 2 + try: + prepped.append(prep_record(raw)) + except ChemistryMappingError as exc: + validation_errors.append(f"Row {row_number}: {exc}") + + prepped = dedupe_records(prepped) + + with session_ctx() as session: + # Resolve every distinct (base) sample point to a Thing up front. + base_pointids = sorted({r["samplepointid"] for r in prepped}) + thing_ids: dict[str, int | None] = { + pid: _resolve_thing_id(session, pid) for pid in base_pointids + } + for pid in base_pointids: + if thing_ids[pid] is None: + validation_errors.append( + f"SamplePointID {pid}: no matching Thing (well) found" + ) + + # Abort the whole file on any data-quality problem before writing. + if validation_errors: + return _result( + processed=processed, + imported=0, + validation_errors=validation_errors, + skipped_duplicates=[], + created=[], + pretty_json=pretty_json, + ) + + # One sample = one lab sample (WCLab_ID) for a well. + def bucket_key(r: dict) -> tuple[str, str | None]: + return (r["samplepointid"], r["wclab_id"]) + + buckets: dict[tuple[str, str | None], list[dict]] = {} + for rec in sorted( + prepped, key=lambda r: (r["samplepointid"], r["wclab_id"] or "") + ): + buckets.setdefault(bucket_key(rec), []).append(rec) + + # Per-Thing set of used suffix incrementors, seeded from the DB and + # extended as we assign new ones within this run. + used_suffixes: dict[int, set[int]] = {} + skipped_duplicates: list[dict] = [] + created: list[dict] = [] + imported = 0 + + for (base, wclab_id), recs in buckets.items(): + thing_id = thing_ids[base] + + # Already ingested this lab sample -> skip (idempotent), keep going. + if _sample_exists_for_wclab(session, thing_id, wclab_id): + skipped_duplicates.append({"pointid": base, "wclab_id": wclab_id}) + continue + + if thing_id not in used_suffixes: + used_suffixes[thing_id] = _existing_suffix_ints(session, thing_id, base) + next_int = ( + max(used_suffixes[thing_id]) + 1 if used_suffixes[thing_id] else 1 + ) + used_suffixes[thing_id].add(next_int) + sample_point_id = f"{base}{_int_to_suffix(next_int)}" + + collection_date = next( + (r["sample_date"] for r in recs if r["sample_date"]), None + ) + info = NMA_Chemistry_SampleInfo( + thing_id=thing_id, + nma_sample_pt_id=uuid.uuid4(), + nma_sample_point_id=sample_point_id, + nma_wclab_id=wclab_id, + analyses_agency=ANALYSES_AGENCY, + collection_date=collection_date, + ) + session.add(info) + session.flush() # assign info.id for the FK below + + for rec in recs: + model = _TABLE_MODEL[rec["table"]] + session.add(_build_measurement(model, info.id, rec, sample_point_id)) + imported += 1 + created.append( + { + "sample_point_id": sample_point_id, + "wclab_id": wclab_id, + "rows": len(recs), + } + ) + + session.commit() + + return _result( + processed=processed, + imported=imported, + validation_errors=validation_errors, + skipped_duplicates=skipped_duplicates, + created=created, + pretty_json=pretty_json, + ) + + +def _result( + *, + processed: int, + imported: int, + validation_errors: list[str], + skipped_duplicates: list[dict], + created: list[dict], + pretty_json: bool, +) -> ChemistryUploadResult: + rows_with_issues = len(validation_errors) + len(skipped_duplicates) + payload = { + "summary": { + "total_rows_processed": processed, + "total_rows_imported": imported, + "validation_errors_or_warnings": rows_with_issues, + "samples_created": len(created), + "samples_skipped": len(skipped_duplicates), + }, + "validation_errors": validation_errors, + "skipped_duplicates": skipped_duplicates, + "created_samples": created, + } + stdout = json.dumps(payload, indent=2 if pretty_json else None) + stderr_parts: list[str] = [] + if validation_errors: + stderr_parts.append("\n".join(validation_errors)) + if skipped_duplicates: + dupes = ", ".join( + f"{d['pointid']} (WCLab_ID {d['wclab_id']})" for d in skipped_duplicates + ) + stderr_parts.append(f"Skipped already-ingested lab sample(s): {dupes}") + stderr = "\n".join(stderr_parts) + # Only a data-quality abort is a failure; skipped duplicates are idempotent. + exit_code = 1 if validation_errors else 0 + return ChemistryUploadResult( + exit_code=exit_code, stdout=stdout, stderr=stderr, payload=payload + ) + + +# ============= EOF ============================================= diff --git a/tests/test_chemistry_drive.py b/tests/test_chemistry_drive.py new file mode 100644 index 000000000..2ad2769fb --- /dev/null +++ b/tests/test_chemistry_drive.py @@ -0,0 +1,219 @@ +# =============================================================================== +# 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 Drive-polling chemistry sync (services/chemistry_drive.py).""" + +import io + +import pytest +from openpyxl import Workbook +from sqlalchemy import delete + +from db.engine import session_ctx +from db.nma_legacy import NMA_Chemistry_SampleInfo +from services import chemistry_drive +from services.chemistry_drive import ( + ChemistryDriveConfigError, + load_manifest, + save_manifest, + sync_and_ingest, +) + +LIMS_HEADER = [ + "Param", + "Results_Units", + "Dilution", + "AnalysisTime", + "SampleNumber", + "CustomerSampleNumber", + "SamplePointID", + "Method", + "Test", + "ReportedND", + "LowerLimit", + "SampleDate", +] + + +def _workbook_bytes(param="calcium", value="12.5", pointid="Test Well"): + wb = Workbook() + ws = wb.active + ws.append(LIMS_HEADER) + ws.append( + [ + param, + "mg/L", + 1, + "2024-06-15", + "LAB-1", + pointid, + pointid, + "EPA 200.7", + "Major", + value, + 0.01, + "2024-06-01", + ] + ) + buffer = io.BytesIO() + wb.save(buffer) + return buffer.getvalue() + + +class FakeBlob: + def __init__(self, store, name): + self._store = store + self._name = name + + def exists(self): + return self._name in self._store + + def download_as_text(self): + return self._store[self._name] + + def upload_from_string(self, data, content_type=None): + self._store[self._name] = data + + +class FakeBucket: + def __init__(self): + self.store = {} + + def blob(self, name): + return FakeBlob(self.store, name) + + +@pytest.fixture() +def fake_bucket(): + return FakeBucket() + + +@pytest.fixture() +def _cleanup_chemistry(): + yield + with session_ctx() as session: + session.execute( + delete(NMA_Chemistry_SampleInfo).where( + NMA_Chemistry_SampleInfo.nma_wclab_id.like("LAB-%") + ) + ) + session.commit() + + +def _stub_drive(monkeypatch, files: list[dict], contents: dict[str, bytes]): + monkeypatch.setattr( + chemistry_drive, "list_drive_xlsx", lambda folder_id, service=None: files + ) + + def _download(file_id, service=None): + return contents[file_id] + + monkeypatch.setattr(chemistry_drive, "download_drive_file", _download) + + +# ------------------------- manifest tests ------------------------------------ + + +def test_manifest_roundtrip(fake_bucket): + assert load_manifest(fake_bucket) == {} + save_manifest({"F1": {"status": "success"}}, fake_bucket) + assert load_manifest(fake_bucket) == {"F1": {"status": "success"}} + + +def test_missing_folder_raises(monkeypatch): + monkeypatch.delenv("CHEMISTRY_DRIVE_FOLDER_ID", raising=False) + with pytest.raises(ChemistryDriveConfigError): + sync_and_ingest(folder_id=None) + + +# ------------------------- sync tests ---------------------------------------- + + +def test_sync_ingests_new_file_and_records_manifest( + monkeypatch, fake_bucket, water_well_thing, _cleanup_chemistry +): + files = [ + {"id": "F1", "name": "batch1.xlsx", "md5Checksum": "abc", "modifiedTime": "t0"} + ] + _stub_drive(monkeypatch, files, {"F1": _workbook_bytes()}) + + result = sync_and_ingest(folder_id="folder", bucket=fake_bucket) + + assert result.exit_code == 0 + assert len(result.ingested) == 1 + assert result.ingested[0]["rows_imported"] == 1 + manifest = load_manifest(fake_bucket) + assert manifest["F1"]["status"] == "success" + assert manifest["F1"]["md5"] == "abc" + + +def test_sync_skips_already_ingested_file( + monkeypatch, fake_bucket, water_well_thing, _cleanup_chemistry +): + files = [ + {"id": "F1", "name": "batch1.xlsx", "md5Checksum": "abc", "modifiedTime": "t0"} + ] + _stub_drive(monkeypatch, files, {"F1": _workbook_bytes()}) + + first = sync_and_ingest(folder_id="folder", bucket=fake_bucket) + assert len(first.ingested) == 1 + + second = sync_and_ingest(folder_id="folder", bucket=fake_bucket) + assert second.ingested == [] + assert second.skipped == ["batch1.xlsx"] + + +def test_dry_run_does_not_download_or_write_manifest( + monkeypatch, fake_bucket, water_well_thing, _cleanup_chemistry +): + files = [ + {"id": "F1", "name": "batch1.xlsx", "md5Checksum": "abc", "modifiedTime": "t0"} + ] + monkeypatch.setattr( + chemistry_drive, "list_drive_xlsx", lambda folder_id, service=None: files + ) + + def _boom(file_id, service=None): + raise AssertionError("download must not be called during a dry run") + + monkeypatch.setattr(chemistry_drive, "download_drive_file", _boom) + + result = sync_and_ingest(folder_id="folder", bucket=fake_bucket, dry_run=True) + + assert result.dry_run is True + assert result.new_files == ["batch1.xlsx"] + assert result.ingested == [] + assert load_manifest(fake_bucket) == {} + + +def test_sync_marks_failed_when_ingestion_aborts( + monkeypatch, fake_bucket, water_well_thing, _cleanup_chemistry +): + # A workbook whose SamplePointID has no matching Thing aborts (validation + # error) -> the file is recorded as failed. + _stub_drive( + monkeypatch, + [{"id": "F1", "name": "bad.xlsx", "md5Checksum": "abc", "modifiedTime": "t0"}], + {"F1": _workbook_bytes(pointid="NO-SUCH-WELL")}, + ) + result = sync_and_ingest(folder_id="folder", bucket=fake_bucket) + + assert result.exit_code == 1 + assert len(result.failed) == 1 + assert result.failed[0]["name"] == "bad.xlsx" + assert load_manifest(fake_bucket)["F1"]["status"] == "failed" + + +# ============= EOF ============================================= diff --git a/tests/test_chemistry_lims.py b/tests/test_chemistry_lims.py new file mode 100644 index 000000000..c95d9575f --- /dev/null +++ b/tests/test_chemistry_lims.py @@ -0,0 +1,287 @@ +# =============================================================================== +# 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 LIMS chemistry ingestion service (services/chemistry_lims.py).""" + +from pathlib import Path + +import pytest +from openpyxl import Workbook +from sqlalchemy import delete, select + +from db.engine import session_ctx +from db.nma_legacy import ( + NMA_Chemistry_SampleInfo, + NMA_MajorChemistry, + NMA_MinorTraceChemistry, +) +from services.chemistry_lims import ( + _int_to_suffix, + _suffix_to_int, + bulk_upload_chemistry, + dedupe_records, + prep_record, +) + +LIMS_HEADER = [ + "Param", + "Results_Units", + "Dilution", + "AnalysisTime", + "SampleNumber", + "CustomerSampleNumber", + "SamplePointID", + "Method", + "Test", + "ReportedND", + "LowerLimit", + "SampleDate", +] + + +def _write_workbook(path: Path, rows: list[dict]) -> Path: + wb = Workbook() + ws = wb.active + ws.append(LIMS_HEADER) + for row in rows: + ws.append([row.get(col) for col in LIMS_HEADER]) + wb.save(path) + return path + + +def _lims_row(param, value, *, pointid="Test Well", method="EPA 200.7", **overrides): + row = { + "Param": param, + "Results_Units": "mg/L", + "Dilution": 1, + "AnalysisTime": "2024-06-15", + "SampleNumber": "LAB-1", + "CustomerSampleNumber": pointid, + "SamplePointID": pointid, + "Method": method, + "Test": "Trace Metals", + "ReportedND": value, + "LowerLimit": 0.01, + "SampleDate": "2024-06-01", + } + row.update(overrides) + return row + + +@pytest.fixture() +def _cleanup_chemistry(): + """Remove any sample-info (and cascaded analytes) created during a test.""" + yield + with session_ctx() as session: + session.execute( + delete(NMA_Chemistry_SampleInfo).where( + NMA_Chemistry_SampleInfo.nma_wclab_id.like("LAB-%") + ) + ) + session.commit() + + +# ------------------------- pure-function tests ------------------------------- + + +def test_prep_record_maps_analyte_and_table(): + rec = prep_record(_lims_row("calcium", "12.5")) + assert rec["analyte"] == "Ca" + assert rec["table"] == "MajorChemistry" + assert rec["sample_value"] == 12.5 + assert rec["symbol"] is None + + +def test_prep_record_non_detect_uses_lower_limit_times_dilution(): + rec = prep_record(_lims_row("lead", "ND", Dilution=2, LowerLimit=0.01)) + assert rec["analyte"] == "Pb" + assert rec["table"] == "MinorandTraceChemistry" + assert rec["symbol"] == "<" + assert rec["sample_value"] == pytest.approx(0.02) + + +def test_prep_record_unmapped_analyte_raises(): + from services.chemistry_lims import ChemistryMappingError + + with pytest.raises(ChemistryMappingError): + prep_record(_lims_row("unobtanium", "1.0")) + + +def test_dedupe_prefers_epa_200_7(): + rows = [ + prep_record(_lims_row("calcium", "10", method="EPA 6010")), + prep_record(_lims_row("calcium", "11", method="EPA 200.7")), + ] + deduped = dedupe_records(rows) + assert len(deduped) == 1 + assert deduped[0]["sample_value"] == 11.0 + + +@pytest.mark.parametrize( + "suffix,number", + [("A", 1), ("B", 2), ("Z", 26), ("AA", 27), ("AB", 28), ("AZ", 52), ("BA", 53)], +) +def test_suffix_bijective_base26_roundtrip(suffix, number): + assert _suffix_to_int(suffix) == number + assert _int_to_suffix(number) == suffix + + +# ------------------------- ingestion tests ----------------------------------- + + +def test_bulk_upload_inserts_major_and_minor( + tmp_path, water_well_thing, _cleanup_chemistry +): + path = _write_workbook( + tmp_path / "lims.xlsx", + [_lims_row("calcium", "12.5"), _lims_row("arsenic", "0.3")], + ) + + result = bulk_upload_chemistry(path) + + assert result.exit_code == 0, result.stderr + assert result.payload["summary"]["total_rows_imported"] == 2 + + with session_ctx() as session: + info = session.scalars( + select(NMA_Chemistry_SampleInfo).where( + NMA_Chemistry_SampleInfo.thing_id == water_well_thing.id + ) + ).one() + major = session.scalars( + select(NMA_MajorChemistry).where( + NMA_MajorChemistry.chemistry_sample_info_id == info.id + ) + ).all() + minor = session.scalars( + select(NMA_MinorTraceChemistry).where( + NMA_MinorTraceChemistry.chemistry_sample_info_id == info.id + ) + ).all() + + assert {m.analyte for m in major} == {"Ca"} + assert {m.analyte for m in minor} == {"As"} + # First sample for the well -> base PointID + "A". + assert info.nma_sample_point_id == "Test WellA" + assert {m.nma_sample_point_id for m in major} == {"Test WellA"} + + +def test_bulk_upload_skips_duplicate_lab_sample( + tmp_path, water_well_thing, _cleanup_chemistry +): + # Same WCLab_ID (SampleNumber) uploaded twice -> second run is idempotent. + rows = [_lims_row("calcium", "12.5", SampleNumber="LAB-1")] + _write_workbook(tmp_path / "first.xlsx", rows) + first = bulk_upload_chemistry(tmp_path / "first.xlsx") + assert first.exit_code == 0, first.stderr + assert first.payload["summary"]["samples_created"] == 1 + + _write_workbook(tmp_path / "second.xlsx", rows) + second = bulk_upload_chemistry(tmp_path / "second.xlsx") + + # Idempotent: no failure, nothing imported, reported as skipped. + assert second.exit_code == 0 + assert second.payload["summary"]["total_rows_imported"] == 0 + assert second.payload["summary"]["samples_skipped"] == 1 + assert second.payload["skipped_duplicates"][0]["wclab_id"] == "LAB-1" + + with session_ctx() as session: + rows_ca = session.scalars( + select(NMA_MajorChemistry).where(NMA_MajorChemistry.analyte == "Ca") + ).all() + assert len(rows_ca) == 1 # not duplicated + + +def test_bulk_upload_appends_new_lab_sample_with_next_suffix( + tmp_path, water_well_thing, _cleanup_chemistry +): + # A different WCLab_ID for the same well -> a new lettered sample point. + _write_workbook( + tmp_path / "first.xlsx", [_lims_row("calcium", "12.5", SampleNumber="LAB-1")] + ) + first = bulk_upload_chemistry(tmp_path / "first.xlsx") + assert first.exit_code == 0, first.stderr + assert first.payload["created_samples"][0]["sample_point_id"] == "Test WellA" + + _write_workbook( + tmp_path / "second.xlsx", [_lims_row("calcium", "9.9", SampleNumber="LAB-2")] + ) + second = bulk_upload_chemistry(tmp_path / "second.xlsx") + assert second.exit_code == 0, second.stderr + assert second.payload["created_samples"][0]["sample_point_id"] == "Test WellB" + + with session_ctx() as session: + infos = session.scalars( + select(NMA_Chemistry_SampleInfo).where( + NMA_Chemistry_SampleInfo.thing_id == water_well_thing.id + ) + ).all() + assert {i.nma_sample_point_id for i in infos} == {"Test WellA", "Test WellB"} + + +def test_bulk_upload_two_lab_samples_in_one_file_get_a_and_b( + tmp_path, water_well_thing, _cleanup_chemistry +): + # Two distinct lab samples in a single workbook -> A and B in one run. + _write_workbook( + tmp_path / "lims.xlsx", + [ + _lims_row("calcium", "12.5", SampleNumber="LAB-1"), + _lims_row("calcium", "9.9", SampleNumber="LAB-2"), + ], + ) + result = bulk_upload_chemistry(tmp_path / "lims.xlsx") + assert result.exit_code == 0, result.stderr + assert result.payload["summary"]["samples_created"] == 2 + + with session_ctx() as session: + infos = session.scalars( + select(NMA_Chemistry_SampleInfo).where( + NMA_Chemistry_SampleInfo.thing_id == water_well_thing.id + ) + ).all() + assert {i.nma_sample_point_id for i in infos} == {"Test WellA", "Test WellB"} + + +def test_bulk_upload_reports_missing_thing(tmp_path, _cleanup_chemistry): + path = _write_workbook( + tmp_path / "lims.xlsx", + [_lims_row("calcium", "12.5", pointid="NO-SUCH-WELL")], + ) + + result = bulk_upload_chemistry(path) + + assert result.exit_code == 1 + assert result.payload["summary"]["total_rows_imported"] == 0 + assert any("no matching Thing" in e for e in result.payload["validation_errors"]) + + +def test_bulk_upload_reports_unmapped_analyte( + tmp_path, water_well_thing, _cleanup_chemistry +): + path = _write_workbook( + tmp_path / "lims.xlsx", + [_lims_row("calcium", "12.5"), _lims_row("unobtanium", "9.9")], + ) + + result = bulk_upload_chemistry(path) + + # Unmapped analyte is a validation error -> whole file aborts, nothing imported. + assert result.exit_code == 1 + assert result.payload["summary"]["total_rows_imported"] == 0 + assert any("Unmapped analyte" in e for e in result.payload["validation_errors"]) + + +# ============= EOF ============================================= diff --git a/uv.lock b/uv.lock index 791d52f03..ac9968137 100644 --- a/uv.lock +++ b/uv.lock @@ -761,6 +761,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "faker" version = "37.12.0" @@ -945,6 +954,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/40/9bdbb60b03a332bd45acb8703da08bbc27d991d35286b62e42acc86d243a/google_api_core-2.31.0-py3-none-any.whl", hash = "sha256:ef79fb3784c71cbac89cbd03301ba0c8fb8ad2aa95d7f9204dd9628f7adf59ab", size = 173102, upload-time = "2026-06-03T14:51:26.729Z" }, ] +[[package]] +name = "google-api-python-client" +version = "2.184.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { 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" } +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" }, +] + [[package]] name = "google-auth" version = "2.55.1" @@ -958,6 +983,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/1d/f6d3ca1ad0725f2e08a1c6915640748a52de2e66596160a4d53b010cccf0/google_auth-2.55.1-py3-none-any.whl", hash = "sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995", size = 252349, upload-time = "2026-06-25T23:38:52.946Z" }, ] +[[package]] +name = "google-auth-httplib2" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b3/f192c8bc7e41e0ebdbd95afcae4783417a34b6a6af62d22daf22c3fd38fc/google_auth_httplib2-0.4.0.tar.gz", hash = "sha256:d5b030a204b7a4b4d553ba9ca701b62481ee2b74419325580be70f7d85ffed35", size = 11161, upload-time = "2026-05-07T08:03:46.878Z" } +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-cloud-core" version = "2.6.0" @@ -1121,6 +1159,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httplib2" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/f5/ccf58de92d61e3ad921119668f54ed36ca1d0cf5dcc5c1657dfb164fd78b/httplib2-0.32.0.tar.gz", hash = "sha256:48a0ef30a42db65d8f3399045e1d09ab0ba66e3b9efc360d07f80ea55d286025", size = 254283, upload-time = "2026-06-26T10:13:56.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/a0/550eec327e5f5c7b732531c489f5307efec41f047b0d703bd4ca1e5ad2db/httplib2-0.32.0-py3-none-any.whl", hash = "sha256:dc6705cacdf3fb0a2aba7629fa33c90fd93e30035db0c157325826be177e4816", size = 93148, upload-time = "2026-06-26T10:13:54.985Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1582,6 +1632,10 @@ dependencies = [ ] [package.dev-dependencies] +cli = [ + { name = "google-api-python-client" }, + { name = "openpyxl" }, +] dev = [ { name = "behave" }, { name = "black" }, @@ -1697,6 +1751,10 @@ requires-dist = [ ] [package.metadata.requires-dev] +cli = [ + { name = "google-api-python-client", specifier = "==2.184.0" }, + { name = "openpyxl", specifier = "==3.1.5" }, +] dev = [ { name = "behave", specifier = ">=1.3.3" }, { name = "black", specifier = ">=26.5.1" }, @@ -1710,6 +1768,18 @@ dev = [ { name = "requests", specifier = ">=2.34.2" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.39.1" @@ -3034,6 +3104,15 @@ 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 = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" From 15040e9fe1f70bbfcd25197d7b38079f7db81019 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 8 Jul 2026 10:08:14 -0600 Subject: [PATCH 139/160] chore(ci): drop CD refresh-materialized-views step Materialized-view refresh is handled by the nightly pg_cron job, so the production/staging/testing deploy workflows no longer need to run `python -m cli.cli refresh-materialized-views`. Refs BDMS-1034 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/CD_production.yml | 10 ---------- .github/workflows/CD_staging.yml | 10 ---------- .github/workflows/CD_testing.yml | 10 ---------- 3 files changed, 30 deletions(-) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 5a0acce62..215c808e1 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -99,16 +99,6 @@ jobs: run: | uv run --no-dev alembic upgrade head - - name: Refresh materialized views on production database - env: - DB_DRIVER: "cloudsql" - CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" - CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" - CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" - CLOUD_SQL_IAM_AUTH: true - run: | - uv run --no-dev python -m cli.cli refresh-materialized-views - - name: Ensure envsubst is available run: | if ! command -v envsubst >/dev/null 2>&1; then diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index e2fa929e3..d6f6b1f6d 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -59,16 +59,6 @@ jobs: run: | uv run --no-dev alembic upgrade head - - name: Refresh materialized views on staging database - env: - DB_DRIVER: "cloudsql" - CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" - CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" - CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" - CLOUD_SQL_IAM_AUTH: true - run: | - uv run --no-dev python -m cli.cli refresh-materialized-views - - name: Ensure envsubst is available run: | if ! command -v envsubst >/dev/null 2>&1; then diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index 7004c5b60..0ec67f29c 100644 --- a/.github/workflows/CD_testing.yml +++ b/.github/workflows/CD_testing.yml @@ -59,16 +59,6 @@ jobs: run: | uv run --no-dev alembic upgrade head - - name: Refresh materialized views on staging database - env: - DB_DRIVER: "cloudsql" - CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" - CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" - CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" - CLOUD_SQL_IAM_AUTH: true - run: | - uv run --no-dev python -m cli.cli refresh-materialized-views - - name: Ensure envsubst is available run: | if ! command -v envsubst >/dev/null 2>&1; then From 986a93c5329a28ec1401b43fa51bd2e0be15a713 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 8 Jul 2026 11:49:55 -0600 Subject: [PATCH 140/160] refactor(cli): simplify chemistry CLI output and analyte mapping - CLI serializes its own JSON from the result payload; drop the pretty_json flag threaded through the service and the pre-baked `stdout` field on ChemistryUploadResult. - Remove the unused `--output json` option from `water-chemistry bulk-upload` and `sync-drive`; they are engineer-facing and have no scripted consumer (programmatic callers use the service functions directly). - Replace the FMapper/AnalyteField classes with a frozen `AnalyteMapping` dataclass, a flat `_ANALYTE_MAPPINGS` list, and a `lookup_analyte()` helper backed by a case-insensitive dict; drop the dead list/tuple and reverse-lookup branches. Co-Authored-By: Claude Opus 4.8 --- cli/cli.py | 30 +----- cli/service_adapter.py | 4 +- services/chemistry_lims.py | 214 ++++++++++++++++++------------------- 3 files changed, 110 insertions(+), 138 deletions(-) diff --git a/cli/cli.py b/cli/cli.py index 8a6432e4f..907437928 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -972,30 +972,21 @@ def water_chemistry_bulk_upload( readable=True, help="Path to LIMS .xlsx workbook containing chemistry results.", ), - output_format: OutputFormat | None = typer.Option( - None, - "--output", - help="Optional output format", - ), theme: ThemeMode = typer.Option( ThemeMode.auto, "--theme", help="Color theme: auto, light, dark." ), ): """ parse a LIMS chemistry workbook and load it into the NMA Major/Minor - chemistry tables. All-or-nothing: if any analyte already exists, or any row - fails to map, nothing is written. + chemistry tables. Each distinct lab sample (WCLab_ID) is appended as a new + lettered sample point; a lab sample already recorded for the well is + skipped. A row that fails to map or references an unknown well aborts the + whole file. """ from cli.service_adapter import chemistry_lims_xlsx colors = _palette(theme) - result = chemistry_lims_xlsx( - file_path, pretty_json=output_format == OutputFormat.json - ) - - if output_format == OutputFormat.json: - typer.echo(result.stdout) - raise typer.Exit(result.exit_code) + result = chemistry_lims_xlsx(file_path) payload = result.payload if isinstance(result.payload, dict) else {} summary = payload.get("summary", {}) @@ -1086,11 +1077,6 @@ def water_chemistry_sync_drive( "--dry-run", help="List new files without downloading, ingesting, or updating the manifest.", ), - output_format: OutputFormat | None = typer.Option( - None, - "--output", - help="Optional output format", - ), theme: ThemeMode = typer.Option( ThemeMode.auto, "--theme", help="Color theme: auto, light, dark." ), @@ -1101,8 +1087,6 @@ def water_chemistry_sync_drive( are ingested; a manifest of ingested files is kept in GCS so already-processed files are skipped. """ - import json as _json - from services.chemistry_drive import ChemistryDriveConfigError, sync_and_ingest colors = _palette(theme) @@ -1112,10 +1096,6 @@ def water_chemistry_sync_drive( typer.secho(str(exc), fg=colors["issue"], bold=True, err=True) raise typer.Exit(1) from exc - if output_format == OutputFormat.json: - typer.echo(_json.dumps(result.to_payload())) - raise typer.Exit(result.exit_code) - summary = result.to_payload()["summary"] header = ( "[CHEMISTRY DRIVE SYNC] DRY RUN" if result.dry_run else "[CHEMISTRY DRIVE SYNC]" diff --git a/cli/service_adapter.py b/cli/service_adapter.py index 9b7c4393e..b438b0040 100644 --- a/cli/service_adapter.py +++ b/cli/service_adapter.py @@ -89,13 +89,13 @@ def water_levels_csv(source_file: Path | str, *, pretty_json: bool = False): return result -def chemistry_lims_xlsx(source_file: Path | str, *, pretty_json: bool = False): +def chemistry_lims_xlsx(source_file: Path | str): from services.chemistry_lims import bulk_upload_chemistry if isinstance(source_file, str): source_file = Path(source_file) - result = bulk_upload_chemistry(source_file, pretty_json=pretty_json) + result = bulk_upload_chemistry(source_file) if result.stderr: print(result.stderr, file=sys.stderr) return result diff --git a/services/chemistry_lims.py b/services/chemistry_lims.py index 25bb6f553..d5e6c8b20 100644 --- a/services/chemistry_lims.py +++ b/services/chemistry_lims.py @@ -23,7 +23,8 @@ ``Chemistry SampleInfo`` table. This adaptation: * reads an ``.xlsx`` workbook with ``openpyxl``, -* maps each LIMS ``Param`` to an analyte code + target table via ``FMapper``, +* maps each LIMS ``Param`` to an analyte code + target table via + :func:`lookup_analyte`, * resolves each ``SamplePointID`` (the base well PointID) to a ``Thing`` by name, * appends each distinct lab sample (``WCLab_ID``) as a new @@ -37,7 +38,6 @@ from __future__ import annotations import io -import json import re import uuid from dataclasses import dataclass @@ -71,101 +71,99 @@ ANALYSES_AGENCY = "NMBGMR" -class AnalyteField: - def __init__(self, xlsfield, dbanalyte, table, units=None, method=None): - self.xlsfield = xlsfield - self.dbanalyte = dbanalyte - self.table = table - self.units = units - self.method = method - - -class FMapper: - def __init__(self): - self._map = [ - AnalyteField("alkalinity as caco3", "ALK", MAJOR, method="As CaCO3"), - AnalyteField("aluminum", "Al", MINOR), - AnalyteField("anions total", "TAn", MAJOR, EPM), - AnalyteField("antimony 121", "Sb", MINOR), - AnalyteField("antimony 123", "Sb", MINOR), - AnalyteField("antimony", "Sb", MINOR), - AnalyteField("arsenic", "As", MINOR), - AnalyteField("barium", "Ba", MINOR), - AnalyteField("beryllium", "Be", MINOR), - AnalyteField( - "bicarbonate (hco3)", "HCO3", MAJOR, method="Alkalinity as HC03" - ), - AnalyteField("boron 11", "B", MINOR), - AnalyteField("boron", "B", MINOR), - AnalyteField("bromide", "Br", MINOR), - AnalyteField("cadmium 111", "Cd", MINOR), - AnalyteField("cadmium", "Cd", MINOR), - AnalyteField("calcium", "Ca", MAJOR), - AnalyteField("carbonate (co3)", "CO3", MAJOR), - AnalyteField("cations total", "TCat", MAJOR, EPM), - AnalyteField("chloride", "Cl", MAJOR), - AnalyteField("chromium", "Cr", MINOR), - AnalyteField("cobalt", "Co", MINOR), - AnalyteField("copper 65", "Cu", MINOR), - AnalyteField("copper", "Cu", MINOR), - AnalyteField("fluoride", "F", MINOR), - AnalyteField("hardness", "HRD", MAJOR, MGL, method="As CaCO3"), - AnalyteField("iron", "Fe", MINOR), - AnalyteField("lead", "Pb", MINOR), - AnalyteField("lithium", "Li", MINOR), - AnalyteField("magnesium", "Mg", MAJOR), - AnalyteField("manganese", "Mn", MINOR), - AnalyteField("mercury", "Hg", MINOR), - AnalyteField("molybdenum 95", "Mo", MINOR), - AnalyteField("molybdenum", "Mo", MINOR), - AnalyteField("nickel", "Ni", MINOR), - AnalyteField("nitrate", "NO3", MINOR), - AnalyteField("nitrite", "NO2", MINOR), - AnalyteField("phosphate", "PO4", MINOR), - AnalyteField("percent difference", "IONBAL", MAJOR, PDIFF), - AnalyteField("potassium", "K", MAJOR), - AnalyteField("selenium", "Se", MINOR), - AnalyteField("siliconDioxide", "SiO2", MINOR), - AnalyteField("sio2", "SiO2", MINOR), - AnalyteField("silicon", "Si", MINOR), - AnalyteField("silver 107", "Ag", MINOR), - AnalyteField("silver", "Ag", MINOR), - AnalyteField("sodium", "Na", MAJOR), - AnalyteField("specific conductance", "CONDLAB", MAJOR, COND), - AnalyteField("strontium", "Sr", MINOR), - AnalyteField("sulfate", "SO4", MAJOR), - AnalyteField("tds calc", "TDS", MAJOR, method="Calculation"), - AnalyteField("thallium", "Tl", MINOR), - AnalyteField("thorium", "Th", MINOR), - AnalyteField("tin", "Sn", MINOR), - AnalyteField("titanium", "Ti", MINOR), - AnalyteField("uranium", "U", MINOR), - AnalyteField("vanadium", "V", MINOR), - AnalyteField("zinc 66", "Zn", MINOR), - AnalyteField("zinc", "Zn", MINOR), - AnalyteField("pH", "pHL", MAJOR, PH), - AnalyteField("ortho phosphate", "PO4", MINOR), - ] - - def values(self): - return self._map - - def get(self, key, attr="xlsfield"): - if key is None: - return None - for p in self._map: - value = getattr(p, attr) - if not isinstance(value, (list, tuple)): - value = (value,) - for vi in value: - if str(vi).lower() == str(key).lower(): - return p - return None +@dataclass(frozen=True) +class AnalyteMapping: + """Maps a LIMS ``Param`` name to its analyte code and target table. + + ``units`` overrides the LIMS-reported units when set; ``method`` is appended + to the LIMS analysis method when set. + """ + lims_param: str + analyte: str + table: str + units: str | None = None + method: str | None = None + + +# Every known LIMS ``Param`` -> analyte mapping (ported from AMPAPI chemfile.py). +_ANALYTE_MAPPINGS: list[AnalyteMapping] = [ + AnalyteMapping("alkalinity as caco3", "ALK", MAJOR, method="As CaCO3"), + AnalyteMapping("aluminum", "Al", MINOR), + AnalyteMapping("anions total", "TAn", MAJOR, EPM), + AnalyteMapping("antimony 121", "Sb", MINOR), + AnalyteMapping("antimony 123", "Sb", MINOR), + AnalyteMapping("antimony", "Sb", MINOR), + AnalyteMapping("arsenic", "As", MINOR), + AnalyteMapping("barium", "Ba", MINOR), + AnalyteMapping("beryllium", "Be", MINOR), + AnalyteMapping("bicarbonate (hco3)", "HCO3", MAJOR, method="Alkalinity as HC03"), + AnalyteMapping("boron 11", "B", MINOR), + AnalyteMapping("boron", "B", MINOR), + AnalyteMapping("bromide", "Br", MINOR), + AnalyteMapping("cadmium 111", "Cd", MINOR), + AnalyteMapping("cadmium", "Cd", MINOR), + AnalyteMapping("calcium", "Ca", MAJOR), + AnalyteMapping("carbonate (co3)", "CO3", MAJOR), + AnalyteMapping("cations total", "TCat", MAJOR, EPM), + AnalyteMapping("chloride", "Cl", MAJOR), + AnalyteMapping("chromium", "Cr", MINOR), + AnalyteMapping("cobalt", "Co", MINOR), + AnalyteMapping("copper 65", "Cu", MINOR), + AnalyteMapping("copper", "Cu", MINOR), + AnalyteMapping("fluoride", "F", MINOR), + AnalyteMapping("hardness", "HRD", MAJOR, MGL, method="As CaCO3"), + AnalyteMapping("iron", "Fe", MINOR), + AnalyteMapping("lead", "Pb", MINOR), + AnalyteMapping("lithium", "Li", MINOR), + AnalyteMapping("magnesium", "Mg", MAJOR), + AnalyteMapping("manganese", "Mn", MINOR), + AnalyteMapping("mercury", "Hg", MINOR), + AnalyteMapping("molybdenum 95", "Mo", MINOR), + AnalyteMapping("molybdenum", "Mo", MINOR), + AnalyteMapping("nickel", "Ni", MINOR), + AnalyteMapping("nitrate", "NO3", MINOR), + AnalyteMapping("nitrite", "NO2", MINOR), + AnalyteMapping("phosphate", "PO4", MINOR), + AnalyteMapping("percent difference", "IONBAL", MAJOR, PDIFF), + AnalyteMapping("potassium", "K", MAJOR), + AnalyteMapping("selenium", "Se", MINOR), + AnalyteMapping("siliconDioxide", "SiO2", MINOR), + AnalyteMapping("sio2", "SiO2", MINOR), + AnalyteMapping("silicon", "Si", MINOR), + AnalyteMapping("silver 107", "Ag", MINOR), + AnalyteMapping("silver", "Ag", MINOR), + AnalyteMapping("sodium", "Na", MAJOR), + AnalyteMapping("specific conductance", "CONDLAB", MAJOR, COND), + AnalyteMapping("strontium", "Sr", MINOR), + AnalyteMapping("sulfate", "SO4", MAJOR), + AnalyteMapping("tds calc", "TDS", MAJOR, method="Calculation"), + AnalyteMapping("thallium", "Tl", MINOR), + AnalyteMapping("thorium", "Th", MINOR), + AnalyteMapping("tin", "Sn", MINOR), + AnalyteMapping("titanium", "Ti", MINOR), + AnalyteMapping("uranium", "U", MINOR), + AnalyteMapping("vanadium", "V", MINOR), + AnalyteMapping("zinc 66", "Zn", MINOR), + AnalyteMapping("zinc", "Zn", MINOR), + AnalyteMapping("pH", "pHL", MAJOR, PH), + AnalyteMapping("ortho phosphate", "PO4", MINOR), +] + +# Case-insensitive lookup by LIMS ``Param`` name. +_ANALYTE_BY_PARAM: dict[str, AnalyteMapping] = { + m.lims_param.lower(): m for m in _ANALYTE_MAPPINGS +} + + +def lookup_analyte(param: str | None) -> AnalyteMapping | None: + """Return the mapping for a LIMS ``Param`` name, or ``None`` if unknown.""" + if param is None: + return None + return _ANALYTE_BY_PARAM.get(str(param).strip().lower()) -FM = FMapper() -# Target ORM model per FMapper table bucket. +# Target ORM model per analyte table bucket. _TABLE_MODEL = {MAJOR: NMA_MajorChemistry, MINOR: NMA_MinorTraceChemistry} @@ -176,7 +174,6 @@ class ChemistryMappingError(Exception): @dataclass class ChemistryUploadResult: exit_code: int - stdout: str stderr: str payload: dict[str, Any] @@ -272,15 +269,15 @@ def prep_record(record: dict) -> dict: Raises :class:`ChemistryMappingError` when the row cannot be mapped. """ param = _get(record, "Param") - pm = FM.get(param) - if pm is None: + mapping = lookup_analyte(param) + if mapping is None: raise ChemistryMappingError(f"Unmapped analyte Param={param!r}") pointid = _get(record, "SamplePointID") or _get(record, "CustomerSampleNumber") if not pointid: raise ChemistryMappingError("Missing SamplePointID") - units = pm.units or _get(record, "Results_Units") + units = mapping.units or _get(record, "Results_Units") reported = _get(record, "ReportedND") if reported is not None and str(reported).upper() == "ND": @@ -294,9 +291,11 @@ def prep_record(record: dict) -> dict: symbol = None analysis_method = _get(record, "Method") - if pm.method: + if mapping.method: analysis_method = ( - f"{analysis_method}, {pm.method}" if analysis_method else pm.method + f"{analysis_method}, {mapping.method}" + if analysis_method + else mapping.method ) analysis_date = _to_datetime(_get(record, "AnalysisTime")) @@ -304,8 +303,8 @@ def prep_record(record: dict) -> dict: wclab_id = _get(record, "SampleNumber") return { - "analyte": pm.dbanalyte, - "table": pm.table, + "analyte": mapping.analyte, + "table": mapping.table, "units": str(units) if units is not None else None, "symbol": symbol, "sample_value": sample_value, @@ -453,7 +452,7 @@ def _build_measurement( def bulk_upload_chemistry( - source: Path | str | bytes, *, pretty_json: bool = False + source: Path | str | bytes, ) -> ChemistryUploadResult: """Ingest a LIMS ``.xlsx`` workbook into the NMA chemistry tables. @@ -482,7 +481,6 @@ def bulk_upload_chemistry( validation_errors=[f"Could not read workbook: {exc}"], skipped_duplicates=[], created=[], - pretty_json=pretty_json, ) processed = len(raw_records) @@ -518,7 +516,6 @@ def bulk_upload_chemistry( validation_errors=validation_errors, skipped_duplicates=[], created=[], - pretty_json=pretty_json, ) # One sample = one lab sample (WCLab_ID) for a well. @@ -588,7 +585,6 @@ def bucket_key(r: dict) -> tuple[str, str | None]: validation_errors=validation_errors, skipped_duplicates=skipped_duplicates, created=created, - pretty_json=pretty_json, ) @@ -599,7 +595,6 @@ def _result( validation_errors: list[str], skipped_duplicates: list[dict], created: list[dict], - pretty_json: bool, ) -> ChemistryUploadResult: rows_with_issues = len(validation_errors) + len(skipped_duplicates) payload = { @@ -614,7 +609,6 @@ def _result( "skipped_duplicates": skipped_duplicates, "created_samples": created, } - stdout = json.dumps(payload, indent=2 if pretty_json else None) stderr_parts: list[str] = [] if validation_errors: stderr_parts.append("\n".join(validation_errors)) @@ -626,9 +620,7 @@ def _result( stderr = "\n".join(stderr_parts) # Only a data-quality abort is a failure; skipped duplicates are idempotent. exit_code = 1 if validation_errors else 0 - return ChemistryUploadResult( - exit_code=exit_code, stdout=stdout, stderr=stderr, payload=payload - ) + return ChemistryUploadResult(exit_code=exit_code, stderr=stderr, payload=payload) # ============= EOF ============================================= From b682bfcbb3da941a6ed86b358c7b378b835e4388 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 8 Jul 2026 11:51:07 -0600 Subject: [PATCH 141/160] docs(chemistry): update runbook for CLI cleanup Reflect the renamed analyte map (`lookup_analyte` / `_ANALYTE_MAPPINGS`, was `FMapper`) and the removal of the `--output json` option. Co-Authored-By: Claude Opus 4.8 --- docs/chemistry-ingestion-runbook.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/chemistry-ingestion-runbook.md b/docs/chemistry-ingestion-runbook.md index d978edc95..6831d3f37 100644 --- a/docs/chemistry-ingestion-runbook.md +++ b/docs/chemistry-ingestion-runbook.md @@ -94,8 +94,6 @@ Then ingest: oco water-chemistry sync-drive # or point at a specific folder: oco water-chemistry sync-drive --folder-id -# machine-readable: -oco water-chemistry sync-drive --output json ``` Read the summary. Buckets: @@ -112,7 +110,7 @@ Read the summary. Buckets: | Reported cause | Meaning | Action | |----------------|---------|--------| -| `Unmapped analyte Param=...` | A LIMS `Param` name is not in the analyte map. | Send the Param name to engineering to add to `FMapper`. | +| `Unmapped analyte Param=...` | A LIMS `Param` name is not in the analyte map. | Send the Param name to engineering to add to `_ANALYTE_MAPPINGS` in `services/chemistry_lims.py`. | | `no matching Thing (well) found` | `SamplePointID` has no Ocotillo well. | Verify the PointID; ensure the well was transferred to Data Services first. | Exit code is non-zero if any file failed. @@ -146,7 +144,7 @@ oco water-chemistry bulk-upload --file /path/to/batch.xlsx ## 6. What the ingest does (summary) For each workbook: map each `Param` to an analyte code + target table (major vs -minor) via `FMapper`; compute the value (non-detects become +minor) via `lookup_analyte`; compute the value (non-detects become `LowerLimit × Dilution` with a `<` symbol); collapse duplicate (SamplePointID, WCLab_ID, analyte) rows (prefer EPA 200.7, or "low bromide" for Br); resolve the base `SamplePointID → Thing`. Then, per distinct lab sample @@ -168,8 +166,8 @@ matching well) aborts the whole file — nothing is written. - **`.xlsx` only.** Legacy `.xls` LIMS exports are not read; the file must be a modern `.xlsx`. - **Fixed analyte map.** Unknown `Param` names fail until engineering adds them - to `FMapper`. Only major + minor analytes are handled — field parameters and - radionuclides are out of scope. + to `_ANALYTE_MAPPINGS`. Only major + minor analytes are handled — field + parameters and radionuclides are out of scope. - **Well must exist first.** `SamplePointID` must already match an Ocotillo `Thing.name`; otherwise the file fails. - **Failed files retry loudly.** A file that fails (e.g. unmapped analyte or a From 9b28ced81ba444495e9b4bd19df1b0f78baa03e8 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 11 Jul 2026 21:59:05 -0600 Subject: [PATCH 142/160] docs: add ADR3 for serving water data via OGC API - EDR Propose adopting OGC API - EDR as the read-only delivery interface for groundwater-level measurements (manual + transducer time series) and water-chemistry analyses, served by pygeoapi as a facade over the existing PostgreSQL/PostGIS database. Decision: two collections (waterlevels, water-chemistry); transducer deployments (Equipment) exposed as EDR instances; collection-level water-level queries return the merged manual + transducer series. FastAPI stays the system of record; GeoServer keeps Features/WMS. Co-Authored-By: Claude Opus 4.8 --- ADR3.md | 275 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 ADR3.md diff --git a/ADR3.md b/ADR3.md new file mode 100644 index 000000000..280555d41 --- /dev/null +++ b/ADR3.md @@ -0,0 +1,275 @@ +# ADR3: Serving Water-Level and Water-Chemistry Data via OGC API - EDR + +## Status + +Proposed. + +## Summary + +This ADR proposes adopting the **OGC API - Environmental Data Retrieval (EDR)** +standard as the public delivery interface for the Bureau's core observational +datasets: groundwater-level measurements (both manual readings and +instrument/transducer time series) and water-chemistry analyses. + +Both datasets are already modeled in this repository as point-located, +time-stamped, parameterized observations tied to a `SampleLocation` geometry. +That shape is exactly what EDR was designed to serve. Adopting EDR gives +external consumers (agencies, researchers, dashboards, other data systems) a +single, standardized, spatiotemporal query interface instead of bespoke +per-dataset REST endpoints, and aligns the project's stated goal of a unified, +interoperable data system (see [ADR1](ADR1.md)). + +The recommendation is to expose EDR as a **read-only query facade layered over +the existing PostgreSQL/PostGIS database**, keeping the FastAPI application as +the system of record and write path. + +## Context + +### What EDR is + +OGC API - EDR is an OpenAPI-based standard for retrieving environmental data at +a position, within an area, along a trajectory, or over a time span. A consumer +does not need to understand the underlying storage. They ask questions like: + +- "Give me depth-to-water at this point, from 2020 to 2024." +- "Give me all nitrate analyses within this polygon." +- "List the locations that have chloride data." + +EDR standardizes these as a small set of **query patterns** over named +**collections**: + +- `/position` — data at a point (optionally with a datetime range) +- `/area` — data within a polygon +- `/radius` — data within a distance of a point +- `/locations` — data at named, discrete sites (the natural fit for wells) +- `/items` — direct access to individual features +- `/cube`, `/trajectory`, `/corridor` — additional patterns we can defer + +Each collection advertises its **parameter-names** (the measured variables), +its spatial and temporal extents, and its output formats. Responses are +typically **CoverageJSON** or GeoJSON. + +### How this maps onto the existing data model + +The mapping is close to one-to-one, which is the main reason EDR is attractive +here rather than in the abstract: + +| EDR concept | This repository | +|----------------------|----------------------------------------------------------------------------------------| +| Location / platform | `Well` → `SampleLocation` (`Geometry(POINT, srid=4326)` in [db/base.py](db/base.py)) | +| `waterlevels` collection | `GroundwaterLevelObservation` via `WellTimeseries` ([db/timeseries.py](db/timeseries.py)) | +| EDR instance | `Equipment` deployment — the transducer/logger recording a series ([db/base.py](db/base.py)) | +| `water-chemistry` collection | `WaterChemistryAnalysis` via `WaterChemistryAnalysisSet` ([db/chemistry.py](db/chemistry.py)) | +| parameter-names | depth-to-water (`value`/`unit`, default `ftbgs`); chemistry `analyte` values | +| datetime axis | `GroundwaterLevelObservation.timestamp`; `WaterChemistryAnalysis.analysis_timestamp` | +| result value + units | `value`, `unit` (unit already normalized against `lexicon_term`) | + +Water levels are a single-parameter (depth-to-water) time series per well. Water +chemistry is a multi-parameter set keyed by `analyte`, where each +`WaterChemistryAnalysisSet` shares a `collection_timestamp` and each child +`WaterChemistryAnalysis` carries its own `analyte`, `value`, `unit`, +`uncertainty`, and `method`. Both fold cleanly into EDR collections whose +primary query pattern is `/locations` (discrete wells) with `/area` and +`/radius` as secondary patterns. + +### Transducer (instrument) observations + +Groundwater levels arrive two ways, and both live in the same +`GroundwaterLevelObservation` table: + +- **Manual measurements** — periodic hand readings, no instrument attached. +- **Transducer observations** — continuous, high-frequency readings from a + deployed pressure transducer or data logger. These are distinguished by a + non-null `WellTimeseries.equipment_id` pointing at an `Equipment` row whose + `equipment_type` is a transducer/logger, with `recording_interval` (cadence), + `date_installed`, and `date_removed` bounding the deployment. + +The two differ mainly in **density and provenance**, not in physical quantity — +both are depth-to-water. EDR models this cleanly with **instances**: each +transducer deployment (`Equipment` bounded by install/removal dates) becomes an +EDR *instance* of the `waterlevels` collection. That preserves per-deployment +temporal extent, resolution (`recording_interval`), and instrument metadata +(`model`, `serial_no`) while keeping a single collection and parameter-name. +Consumers can query the whole well series or drill into one instrument +deployment. The dense transducer axis is also the primary motivation for +supporting the `/cube` and datetime-ranged `/position` patterns, not just +`/locations`. + +### Why now + +This branch introduces GeoServer as spatial infrastructure (see +`geoserver_iac/`). GeoServer covers WMS, WFS, and OGC API - Features well, but +**GeoServer does not implement OGC API - EDR**. Feature access alone does not +give consumers the position/area/time query semantics that observational data +needs. This ADR fills that gap and clarifies the division of labor between +GeoServer (features, maps) and the EDR facade (observations, time series). + +## Decision Drivers + +- **Interoperability** — a published OGC standard beats bespoke endpoints for + cross-agency and cross-system consumption. Directly serves the ADR1 goal. +- **Fit to data** — the data is already point + time + parameter; EDR is built + for exactly that. Minimal impedance mismatch. +- **Separation of concerns** — keep FastAPI as the authoritative write/QC path; + expose a read-only, cacheable query surface for delivery. +- **Standards, not lock-in** — EDR is client-agnostic; any EDR client works. +- **Incremental adoption** — start with two collections and the two most useful + query patterns; expand later without breaking the contract. + +## Considered Options + +### Option A — pygeoapi as an EDR facade over PostgreSQL/PostGIS (recommended) + +Run [pygeoapi](https://pygeoapi.io/) as a separate read-only service configured +with two EDR collections backed by the existing database (via custom EDR +providers, or SQL views shaped for pygeoapi's providers). pygeoapi is a +reference implementation of OGC API - EDR and already appears transitively in +the environment. + +- **Pros:** standards-compliant EDR out of the box (query patterns, + CoverageJSON, OpenAPI, conformance) with little bespoke protocol code; keeps + the write path untouched; deployable alongside GeoServer as another + read service. +- **Cons:** a second service and config surface to operate; custom providers + needed to bridge the normalized schema (well → timeseries → observation) into + EDR's collection/parameter model; two mental models (FastAPI + pygeoapi). + +### Option B — native EDR endpoints inside the existing FastAPI app + +Implement the EDR query patterns directly as FastAPI routes and hand-roll +CoverageJSON serialization. + +- **Pros:** one service, one deployment, one auth story; full control over + query translation and reuse of existing SQLAlchemy models and helpers. +- **Cons:** we reimplement a spec that already has a reference implementation; + ongoing burden to stay conformant (query-parameter parsing, CoverageJSON, + OpenAPI/conformance docs, edge cases). Highest long-term maintenance cost. + +### Option C — GeoServer only (OGC API - Features) + +Publish wells and observations as feature collections and let consumers filter. + +- **Pros:** already on this branch; no new service. +- **Cons:** Features is not EDR. No position/area/time query semantics, no + parameter/coverage model, no CoverageJSON. Poor fit for time-series retrieval; + pushes filtering and reshaping onto every client. Rejected as the primary + delivery mechanism for observations. + +### Option D — do nothing (keep bespoke REST) + +Continue serving via `api/timeseries.py` and `api/chemisty.py`. + +- **Pros:** zero new work. +- **Cons:** no standardization, no interoperability, every consumer integrates + against a custom contract. Fails the ADR1 unification goal. + +## Decision + +Adopt **Option A**: expose water-level and water-chemistry data through **OGC +API - EDR served by pygeoapi as a read-only facade** over the existing +PostgreSQL/PostGIS database. + +Scope for the first iteration: + +- **Collections:** `waterlevels` (manual + transducer) and `water-chemistry`. +- **Query patterns:** `/locations` (primary), `/area` and `/radius` + (secondary), plus `/collections` metadata. `/instances` for transducer + deployments, and datetime-ranged `/position` + `/cube` for dense transducer + series. +- **Manual + transducer merge:** a collection-level query at a well returns the + **merged** depth-to-water series — manual readings and transducer readings on + a single time axis. Transducer data is visible without the consumer needing to + know instances exist. +- **Instances:** each transducer/logger `Equipment` deployment is *also* exposed + as an EDR instance of `waterlevels`, carrying its temporal extent + (`date_installed`/`date_removed`), resolution (`recording_interval`), and + instrument metadata (`model`, `serial_no`). Instances are the drill-down path + to isolate one deployment; they do not hide data from the merged series. +- **Parameter-names:** `waterlevels` exposes a single depth-to-water parameter + (manual and transducer readings share it; measurement method is carried as + metadata / instance, not as a separate parameter); `water-chemistry` exposes + one parameter per `analyte` present in the lexicon. +- **Output formats:** CoverageJSON (primary) and GeoJSON. +- **CRS / units:** EPSG:4326 (consistent with `SampleLocation` and the + project's geopackage SRS convention); units carried from `unit` and declared + per parameter. +- **Boundary:** EDR is read-only. All writes, validation, and QC stay in the + FastAPI application. Only QC-approved / visible records are published + (`WaterChemistryAnalysisSet.visible`, and `quality_control_status` on + observations). + +FastAPI remains the system of record. GeoServer remains responsible for maps +and OGC API - Features. pygeoapi owns the EDR observational surface. + +## Consequences + +### Positive + +- One standardized, self-describing spatiotemporal interface for the two most + requested observational datasets. +- Consumers use off-the-shelf EDR clients; no custom SDK required. +- Clean separation: authoritative write path (FastAPI) vs. cacheable read path + (pygeoapi/EDR), which also helps the concurrency posture discussed in ADR2. +- Extensible: new collections (e.g. geothermal, geochronology) follow the same + pattern later. + +### Negative / costs + +- A new service to deploy, monitor, and secure (align with the existing + GeoServer IaC on this branch). +- Custom EDR providers or purpose-built SQL views are required to bridge the + normalized relational schema into EDR collections and parameters. +- Two frameworks in the delivery stack (FastAPI + pygeoapi) to keep in sync as + the schema evolves. + +### Risks and open questions + +- **Schema bridging** — well → timeseries → observation and set → analysis are + joins, not flat tables. Decide between custom pygeoapi providers vs. dedicated + read views/materialized views. Views are likely simpler to start. +- **Chemistry parameter cardinality** — number of `analyte` values drives the + parameter list; confirm this is bounded and lexicon-governed before exposing + every analyte as a parameter-name. +- **Transducer volume and cadence** — continuous transducer series can be large + and dense. Confirm response paging/limits, decide default vs. maximum datetime + windows, and consider server-side decimation/aggregation for wide `/cube` + queries. High-frequency reads are the strongest case for caching the EDR + facade. +- **Manual vs. transducer disambiguation** — the query that splits the two is + presence of `WellTimeseries.equipment_id` (and transducer `equipment_type`). + Confirm `equipment_type` values are lexicon-governed so instance selection is + reliable. **Decided:** a collection-level query at a well returns the merged + series (manual + transducer on one time axis); instances remain available to + isolate a single transducer deployment (see Decision). +- **Private-IP database access** — the facade must reach Cloud SQL over the + private IP path (`10.10.0.3`), consistent with the datastore convention noted + for GeoServer; public IP returns 502s. +- **Publication gating** — enforce that only `visible` / QC-approved records + reach EDR, ideally at the view layer so it cannot be bypassed. +- **Units and vocabularies** — declare EDR parameter units and definitions from + the `lexicon_term` table so the standard's parameter metadata stays truthful. + +## Acceptance Criteria + +- `GET /collections` lists `waterlevels` and `water-chemistry` with correct + spatial extents, temporal extents, and parameter-names. +- `GET /collections/waterlevels/locations/{wellId}?datetime=...` returns + CoverageJSON depth-to-water for a real well over a bounded time range, + covering both manual and transducer readings. +- `GET /collections/waterlevels/instances` lists transducer deployments for a + well with correct temporal extents and resolution, and querying one instance + returns only that deployment's dense series. +- `GET /collections/water-chemistry/area?coords=...¶meter-name=...` returns + the expected analyses for a polygon, filtered by analyte. +- Only QC-approved / visible records appear in EDR responses. +- The service reaches Cloud SQL over the private IP and passes a read-only + smoke test against production-shaped data. +- OpenAPI and conformance documents validate against the EDR spec. + +## Notes + +- Related: [ADR1](ADR1.md) (unification goal), ADR2 (API concurrency — the + read/write split here reinforces that direction), and the GeoServer IaC on + the `geoserver-iac` branch (complementary Features/WMS surface). +- This ADR decides direction and boundaries, not a file-by-file implementation + plan. Provider-vs-view choice and deployment wiring are follow-up work. From f3ee4dae23679e781fa01bf4da0e0edb478ac790 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 11 Jul 2026 22:04:08 -0600 Subject: [PATCH 143/160] docs: drop private-IP db access note from ADR3 Remove the Cloud SQL private-IP (10.10.0.3) risk bullet and its matching acceptance-criterion line. Co-Authored-By: Claude Opus 4.8 --- ADR3.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ADR3.md b/ADR3.md index 280555d41..4b460543d 100644 --- a/ADR3.md +++ b/ADR3.md @@ -241,9 +241,6 @@ and OGC API - Features. pygeoapi owns the EDR observational surface. reliable. **Decided:** a collection-level query at a well returns the merged series (manual + transducer on one time axis); instances remain available to isolate a single transducer deployment (see Decision). -- **Private-IP database access** — the facade must reach Cloud SQL over the - private IP path (`10.10.0.3`), consistent with the datastore convention noted - for GeoServer; public IP returns 502s. - **Publication gating** — enforce that only `visible` / QC-approved records reach EDR, ideally at the view layer so it cannot be bypassed. - **Units and vocabularies** — declare EDR parameter units and definitions from @@ -262,8 +259,7 @@ and OGC API - Features. pygeoapi owns the EDR observational surface. - `GET /collections/water-chemistry/area?coords=...¶meter-name=...` returns the expected analyses for a polygon, filtered by analyte. - Only QC-approved / visible records appear in EDR responses. -- The service reaches Cloud SQL over the private IP and passes a read-only - smoke test against production-shaped data. +- The service passes a read-only smoke test against production-shaped data. - OpenAPI and conformance documents validate against the EDR spec. ## Notes From ae62e5cedbc6cf6c89fddba827cface26f34f920 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 12 Jul 2026 08:59:47 -0600 Subject: [PATCH 144/160] docs: correct ADR3 to staging schema and add EDR BDD spec Rewrite ADR3 against the real staging models (Thing/Location, Observation, TransducerObservation/TransducerObservationBlock/Deployment/Sensor, Sample/Parameter) instead of the divergent geoserver-iac branch schema. Key corrections: - EDR is added to the pygeoapi service already mounted at /ogcapi (core/pygeoapi.py), not a new standalone service. - Publication gating uses release_status = 'public' via ogc_* views, matching the existing OGC API - Features pattern. - Parameter names come from Parameter.parameter_name; units from default_unit. - Note that pygeoapi's bundled EDR providers are gridded, so an observational PostgreSQL-backed EDR provider is likely required. Add the executable spec: - tests/features/edr-water-data.feature (@edr @wip, 9 scenarios) - tests/features/steps/edr_water_data.py (reuses the in-process TestClient; Background skips while the EDR collections are unimplemented) Co-Authored-By: Claude Opus 4.8 --- ADR3.md | 343 ++++++++++++----------- tests/features/edr-water-data.feature | 88 ++++++ tests/features/steps/edr_water_data.py | 367 +++++++++++++++++++++++++ 3 files changed, 645 insertions(+), 153 deletions(-) create mode 100644 tests/features/edr-water-data.feature create mode 100644 tests/features/steps/edr_water_data.py diff --git a/ADR3.md b/ADR3.md index 4b460543d..1d96f213a 100644 --- a/ADR3.md +++ b/ADR3.md @@ -7,33 +7,35 @@ Proposed. ## Summary This ADR proposes adopting the **OGC API - Environmental Data Retrieval (EDR)** -standard as the public delivery interface for the Bureau's core observational +standard as the delivery interface for the Bureau's core observational datasets: groundwater-level measurements (both manual readings and instrument/transducer time series) and water-chemistry analyses. -Both datasets are already modeled in this repository as point-located, -time-stamped, parameterized observations tied to a `SampleLocation` geometry. -That shape is exactly what EDR was designed to serve. Adopting EDR gives -external consumers (agencies, researchers, dashboards, other data systems) a -single, standardized, spatiotemporal query interface instead of bespoke -per-dataset REST endpoints, and aligns the project's stated goal of a unified, +These datasets are already modeled in this repository as point-located, +time-stamped, parameterized observations tied to a `Location` geometry. That +shape is exactly what EDR was designed to serve. Adopting EDR gives external +consumers (agencies, researchers, dashboards, other data systems) a single, +standardized, spatiotemporal query interface instead of bespoke per-dataset +REST endpoints, and aligns the project's stated goal of a unified, interoperable data system (see [ADR1](ADR1.md)). -The recommendation is to expose EDR as a **read-only query facade layered over -the existing PostgreSQL/PostGIS database**, keeping the FastAPI application as -the system of record and write path. +The recommendation is to **add EDR collections to the pygeoapi service that is +already mounted at `/ogcapi`** (see [core/pygeoapi.py](core/pygeoapi.py)), +backing them with read-only, publication-filtered database views — the same +pattern the existing OGC API - Features collections already use. The FastAPI +application remains the system of record and the write/QC path. ## Context ### What EDR is OGC API - EDR is an OpenAPI-based standard for retrieving environmental data at -a position, within an area, along a trajectory, or over a time span. A consumer +a position, within an area, at named locations, or over a time span. A consumer does not need to understand the underlying storage. They ask questions like: -- "Give me depth-to-water at this point, from 2020 to 2024." -- "Give me all nitrate analyses within this polygon." -- "List the locations that have chloride data." +- "Give me depth-to-water at this well, from 2020 to 2024." +- "Give me all pH analyses within this polygon." +- "List the transducer deployments recording at this well." EDR standardizes these as a small set of **query patterns** over named **collections**: @@ -42,67 +44,77 @@ EDR standardizes these as a small set of **query patterns** over named - `/area` — data within a polygon - `/radius` — data within a distance of a point - `/locations` — data at named, discrete sites (the natural fit for wells) -- `/items` — direct access to individual features +- `/instances` — sub-series of a collection (the natural fit for a transducer + deployment) - `/cube`, `/trajectory`, `/corridor` — additional patterns we can defer Each collection advertises its **parameter-names** (the measured variables), -its spatial and temporal extents, and its output formats. Responses are -typically **CoverageJSON** or GeoJSON. +its spatial and temporal extents, and its output formats. EDR responses are +**CoverageJSON**. -### How this maps onto the existing data model +### The existing OGC surface + +pygeoapi is **already running** in this application, mounted at `/ogcapi` by +[core/pygeoapi.py](core/pygeoapi.py). It currently serves OGC API - **Features** +collections (`water_wells`, `springs`, `perennial_streams`, …), each backed by +an `ogc_` PostgreSQL view filtered to `release_status = 'public'`. So the +standards server, the publication-gating pattern, and the config-generation +machinery all exist today. + +What is missing is **EDR**. Features answers "where are the wells?" and returns +point geometries with summary attributes; it does not answer "what is the +depth-to-water time series at this well between two dates?" as a coverage. EDR +is the query model built for that, and this ADR adds it alongside the existing +Features collections on the same mount. + +### How this maps onto the actual data model The mapping is close to one-to-one, which is the main reason EDR is attractive here rather than in the abstract: -| EDR concept | This repository | -|----------------------|----------------------------------------------------------------------------------------| -| Location / platform | `Well` → `SampleLocation` (`Geometry(POINT, srid=4326)` in [db/base.py](db/base.py)) | -| `waterlevels` collection | `GroundwaterLevelObservation` via `WellTimeseries` ([db/timeseries.py](db/timeseries.py)) | -| EDR instance | `Equipment` deployment — the transducer/logger recording a series ([db/base.py](db/base.py)) | -| `water-chemistry` collection | `WaterChemistryAnalysis` via `WaterChemistryAnalysisSet` ([db/chemistry.py](db/chemistry.py)) | -| parameter-names | depth-to-water (`value`/`unit`, default `ftbgs`); chemistry `analyte` values | -| datetime axis | `GroundwaterLevelObservation.timestamp`; `WaterChemistryAnalysis.analysis_timestamp` | -| result value + units | `value`, `unit` (unit already normalized against `lexicon_term`) | +| EDR concept | This repository (staging schema) | +|----------------------|--------------------------------------------------------------------------------------------------| +| Location / platform | `Thing` (`thing_type = "water well"`) sited via `Location.point` ([db/thing.py](db/thing.py), [db/location.py](db/location.py)) | +| `waterlevels` — manual | `Observation` where `parameter` = "groundwater level" ([db/observation.py](db/observation.py)) | +| `waterlevels` — transducer | `TransducerObservation`, grouped by `TransducerObservationBlock`, per `Deployment` ([db/transducer.py](db/transducer.py), [db/deployment.py](db/deployment.py)) | +| `water_chemistry` collection | `Observation` tied to a `Sample`, keyed by `Parameter` analyte ([db/sample.py](db/sample.py), [db/parameter.py](db/parameter.py)) | +| EDR instance | `Deployment` — a `Sensor` install bounded by `installation_date`/`removal_date` ([db/sensor.py](db/sensor.py)) | +| parameter-names | `Parameter.parameter_name` (e.g. "groundwater level", "pH", chemistry analytes) | +| datetime axis | `Observation.observation_datetime`; `TransducerObservation.observation_datetime` | +| result value + units | `Observation.value` / `TransducerObservation.value`; units from `Parameter.default_unit` (e.g. "ft") | +| publication gate | `release_status` (`ReleaseMixin`), exposed only where `= 'public'` via `ogc_*` views | Water levels are a single-parameter (depth-to-water) time series per well. Water -chemistry is a multi-parameter set keyed by `analyte`, where each -`WaterChemistryAnalysisSet` shares a `collection_timestamp` and each child -`WaterChemistryAnalysis` carries its own `analyte`, `value`, `unit`, -`uncertainty`, and `method`. Both fold cleanly into EDR collections whose -primary query pattern is `/locations` (discrete wells) with `/area` and -`/radius` as secondary patterns. +chemistry is multi-parameter: a `Sample` collected at a well has many +`Observation` rows, each carrying one `Parameter` analyte, a `value`, an +`analysis_method`, and a unit from `Parameter.default_unit`. Both fold cleanly +into EDR collections whose primary query pattern is `/locations` (discrete +wells) with `/area` and `/radius` as secondary patterns. ### Transducer (instrument) observations -Groundwater levels arrive two ways, and both live in the same -`GroundwaterLevelObservation` table: +Groundwater levels arrive two ways, from two different tables: -- **Manual measurements** — periodic hand readings, no instrument attached. -- **Transducer observations** — continuous, high-frequency readings from a - deployed pressure transducer or data logger. These are distinguished by a - non-null `WellTimeseries.equipment_id` pointing at an `Equipment` row whose - `equipment_type` is a transducer/logger, with `recording_interval` (cadence), - `date_installed`, and `date_removed` bounding the deployment. +- **Manual measurements** — `Observation` rows (parameter "groundwater level"), + optionally linked to the `Sensor`/`Sample`/`AnalysisMethod` used; periodic + hand readings. +- **Transducer observations** — `TransducerObservation` rows: continuous, + high-frequency readings from a deployed pressure transducer or logger. Each + row references a `Deployment` (`deployment_id`) and a `Parameter`, and is + grouped for review by a `TransducerObservationBlock` (`start_datetime`, + `end_datetime`, `review_status`, `reviewer`). A `Deployment` records the + `Sensor`, `installation_date`, `removal_date`, and `recording_interval`. The two differ mainly in **density and provenance**, not in physical quantity — both are depth-to-water. EDR models this cleanly with **instances**: each -transducer deployment (`Equipment` bounded by install/removal dates) becomes an -EDR *instance* of the `waterlevels` collection. That preserves per-deployment -temporal extent, resolution (`recording_interval`), and instrument metadata -(`model`, `serial_no`) while keeping a single collection and parameter-name. -Consumers can query the whole well series or drill into one instrument -deployment. The dense transducer axis is also the primary motivation for -supporting the `/cube` and datetime-ranged `/position` patterns, not just -`/locations`. - -### Why now - -This branch introduces GeoServer as spatial infrastructure (see -`geoserver_iac/`). GeoServer covers WMS, WFS, and OGC API - Features well, but -**GeoServer does not implement OGC API - EDR**. Feature access alone does not -give consumers the position/area/time query semantics that observational data -needs. This ADR fills that gap and clarifies the division of labor between -GeoServer (features, maps) and the EDR facade (observations, time series). +transducer `Deployment` becomes an EDR *instance* of the `waterlevels` +collection. That preserves per-deployment temporal extent +(`installation_date`/`removal_date`), resolution (`recording_interval`), and +instrument metadata (`Sensor.model`, `Sensor.serial_no`) while keeping a single +collection and parameter-name. Consumers can query the whole well series or +drill into one deployment. The dense transducer axis is also the primary +motivation for supporting the `/cube` and datetime-ranged `/position` patterns, +not just `/locations`. ## Decision Drivers @@ -110,54 +122,58 @@ GeoServer (features, maps) and the EDR facade (observations, time series). cross-agency and cross-system consumption. Directly serves the ADR1 goal. - **Fit to data** — the data is already point + time + parameter; EDR is built for exactly that. Minimal impedance mismatch. +- **Reuse existing infrastructure** — pygeoapi, the `/ogcapi` mount, the + `ogc_*` publication-view pattern, and the config generator are already in + production for Features. EDR extends them rather than standing up something new. - **Separation of concerns** — keep FastAPI as the authoritative write/QC path; expose a read-only, cacheable query surface for delivery. -- **Standards, not lock-in** — EDR is client-agnostic; any EDR client works. -- **Incremental adoption** — start with two collections and the two most useful +- **Incremental adoption** — start with two collections and the most useful query patterns; expand later without breaking the contract. ## Considered Options -### Option A — pygeoapi as an EDR facade over PostgreSQL/PostGIS (recommended) +### Option A — add EDR collections to the existing pygeoapi mount (recommended) -Run [pygeoapi](https://pygeoapi.io/) as a separate read-only service configured -with two EDR collections backed by the existing database (via custom EDR -providers, or SQL views shaped for pygeoapi's providers). pygeoapi is a -reference implementation of OGC API - EDR and already appears transitively in -the environment. +Extend [core/pygeoapi.py](core/pygeoapi.py) with EDR collection definitions +(alongside `THING_COLLECTIONS`) for `waterlevels` and `water_chemistry`, each +using an EDR provider over publication-filtered `ogc_*` views/materialized +views. Same server, same mount, same gating pattern as Features today. -- **Pros:** standards-compliant EDR out of the box (query patterns, - CoverageJSON, OpenAPI, conformance) with little bespoke protocol code; keeps - the write path untouched; deployable alongside GeoServer as another - read service. -- **Cons:** a second service and config surface to operate; custom providers - needed to bridge the normalized schema (well → timeseries → observation) into - EDR's collection/parameter model; two mental models (FastAPI + pygeoapi). +- **Pros:** standards-compliant EDR (query patterns, CoverageJSON, OpenAPI, + conformance) with no new service; reuses the deployment, config generation, + and `release_status='public'` view convention already in place; keeps the + write path untouched. +- **Cons:** pygeoapi's built-in EDR providers target gridded/xarray data, so an + observational **PostgreSQL-backed EDR provider** (or a thin custom provider) + is needed to serve point/time-series coverages from the relational schema; + bridging `Thing`/`Observation`/`TransducerObservation`/`Deployment` into + EDR collections and instances requires purpose-built read views. -### Option B — native EDR endpoints inside the existing FastAPI app +### Option B — native EDR endpoints inside the FastAPI app Implement the EDR query patterns directly as FastAPI routes and hand-roll CoverageJSON serialization. -- **Pros:** one service, one deployment, one auth story; full control over - query translation and reuse of existing SQLAlchemy models and helpers. -- **Cons:** we reimplement a spec that already has a reference implementation; - ongoing burden to stay conformant (query-parameter parsing, CoverageJSON, - OpenAPI/conformance docs, edge cases). Highest long-term maintenance cost. +- **Pros:** full control over query translation; reuse of existing SQLAlchemy + models and helpers; no dependence on pygeoapi's EDR provider maturity. +- **Cons:** reimplements a spec pygeoapi already largely provides; ongoing + burden to stay conformant (query-parameter parsing, CoverageJSON, OpenAPI / + conformance docs, edge cases); a second OGC surface to keep consistent with + the `/ogcapi` Features mount. Highest long-term maintenance cost. -### Option C — GeoServer only (OGC API - Features) +### Option C — OGC API - Features only -Publish wells and observations as feature collections and let consumers filter. +Publish observations as feature collections and let consumers filter. -- **Pros:** already on this branch; no new service. -- **Cons:** Features is not EDR. No position/area/time query semantics, no +- **Pros:** already deployed; no new work. +- **Cons:** Features is not EDR — no position/area/time query semantics, no parameter/coverage model, no CoverageJSON. Poor fit for time-series retrieval; pushes filtering and reshaping onto every client. Rejected as the primary delivery mechanism for observations. ### Option D — do nothing (keep bespoke REST) -Continue serving via `api/timeseries.py` and `api/chemisty.py`. +Continue serving observations through the existing FastAPI observation routes. - **Pros:** zero new work. - **Cons:** no standardization, no interoperability, every consumer integrates @@ -166,106 +182,127 @@ Continue serving via `api/timeseries.py` and `api/chemisty.py`. ## Decision Adopt **Option A**: expose water-level and water-chemistry data through **OGC -API - EDR served by pygeoapi as a read-only facade** over the existing -PostgreSQL/PostGIS database. +API - EDR collections added to the existing pygeoapi `/ogcapi` mount**, backed +by read-only, publication-filtered database views. Scope for the first iteration: -- **Collections:** `waterlevels` (manual + transducer) and `water-chemistry`. +- **Collections:** `waterlevels` (manual + transducer) and `water_chemistry`, + registered next to the current Features collections in + [core/pygeoapi.py](core/pygeoapi.py). +- **Backing views:** `ogc_waterlevels` and `ogc_water_chemistry` (Alembic-managed, + following the existing `ogc_` convention), each filtered to + `release_status = 'public'`. - **Query patterns:** `/locations` (primary), `/area` and `/radius` (secondary), plus `/collections` metadata. `/instances` for transducer deployments, and datetime-ranged `/position` + `/cube` for dense transducer series. - **Manual + transducer merge:** a collection-level query at a well returns the - **merged** depth-to-water series — manual readings and transducer readings on - a single time axis. Transducer data is visible without the consumer needing to - know instances exist. -- **Instances:** each transducer/logger `Equipment` deployment is *also* exposed - as an EDR instance of `waterlevels`, carrying its temporal extent - (`date_installed`/`date_removed`), resolution (`recording_interval`), and - instrument metadata (`model`, `serial_no`). Instances are the drill-down path - to isolate one deployment; they do not hide data from the merged series. -- **Parameter-names:** `waterlevels` exposes a single depth-to-water parameter - (manual and transducer readings share it; measurement method is carried as - metadata / instance, not as a separate parameter); `water-chemistry` exposes - one parameter per `analyte` present in the lexicon. -- **Output formats:** CoverageJSON (primary) and GeoJSON. -- **CRS / units:** EPSG:4326 (consistent with `SampleLocation` and the - project's geopackage SRS convention); units carried from `unit` and declared - per parameter. + **merged** depth-to-water series — `Observation` (manual) and + `TransducerObservation` (instrument) readings on a single time axis. + Transducer data is visible without the consumer needing to know instances + exist. +- **Instances:** each transducer `Deployment` is *also* exposed as an EDR + instance of `waterlevels`, carrying its temporal extent + (`installation_date`/`removal_date`), resolution (`recording_interval`), and + instrument metadata (`Sensor.model`, `Sensor.serial_no`). Instances are the + drill-down path to isolate one deployment; they do not hide data from the + merged series. +- **Parameter-names:** taken from `Parameter.parameter_name`. `waterlevels` + exposes the single "groundwater level" parameter (manual and transducer share + it; measurement method is carried as metadata / instance, not a separate + parameter). `water_chemistry` exposes one parameter per analyte present. +- **Output format:** CoverageJSON. +- **CRS / units:** CRS84 / EPSG:4326 (consistent with `Location.point` and the + bbox the mount already advertises); units declared per parameter from + `Parameter.default_unit`. - **Boundary:** EDR is read-only. All writes, validation, and QC stay in the - FastAPI application. Only QC-approved / visible records are published - (`WaterChemistryAnalysisSet.visible`, and `quality_control_status` on - observations). + FastAPI application. Only `release_status = 'public'` records are published, + enforced at the `ogc_*` view layer so it cannot be bypassed. -FastAPI remains the system of record. GeoServer remains responsible for maps -and OGC API - Features. pygeoapi owns the EDR observational surface. +FastAPI remains the system of record. pygeoapi owns the OGC read surface — +Features today, plus EDR after this ADR. ## Consequences ### Positive - One standardized, self-describing spatiotemporal interface for the two most - requested observational datasets. + requested observational datasets, on infrastructure already in production. - Consumers use off-the-shelf EDR clients; no custom SDK required. +- Publication gating reuses the proven `ogc_*` / `release_status='public'` + view pattern, so public/private handling is consistent with Features. - Clean separation: authoritative write path (FastAPI) vs. cacheable read path - (pygeoapi/EDR), which also helps the concurrency posture discussed in ADR2. -- Extensible: new collections (e.g. geothermal, geochronology) follow the same - pattern later. + (pygeoapi/EDR), which also reinforces the read/write split discussed in ADR2. +- Extensible: further collections (e.g. geothermal, geochronology) can follow + the same pattern later. ### Negative / costs -- A new service to deploy, monitor, and secure (align with the existing - GeoServer IaC on this branch). -- Custom EDR providers or purpose-built SQL views are required to bridge the - normalized relational schema into EDR collections and parameters. -- Two frameworks in the delivery stack (FastAPI + pygeoapi) to keep in sync as - the schema evolves. +- An observational PostgreSQL-backed EDR provider is likely required, since + pygeoapi's bundled EDR providers target gridded data rather than relational + point/time-series. +- Purpose-built `ogc_*` read views/materialized views are needed to bridge the + normalized schema (`Thing` → `Observation` / `TransducerObservation` / + `Deployment`; `Sample` → `Observation`) into EDR collections and instances. +- More surface area in the generated pygeoapi config and its Alembic-managed + backing views to maintain as the schema evolves. ### Risks and open questions -- **Schema bridging** — well → timeseries → observation and set → analysis are - joins, not flat tables. Decide between custom pygeoapi providers vs. dedicated - read views/materialized views. Views are likely simpler to start. -- **Chemistry parameter cardinality** — number of `analyte` values drives the - parameter list; confirm this is bounded and lexicon-governed before exposing - every analyte as a parameter-name. -- **Transducer volume and cadence** — continuous transducer series can be large - and dense. Confirm response paging/limits, decide default vs. maximum datetime - windows, and consider server-side decimation/aggregation for wide `/cube` - queries. High-frequency reads are the strongest case for caching the EDR - facade. -- **Manual vs. transducer disambiguation** — the query that splits the two is - presence of `WellTimeseries.equipment_id` (and transducer `equipment_type`). - Confirm `equipment_type` values are lexicon-governed so instance selection is - reliable. **Decided:** a collection-level query at a well returns the merged - series (manual + transducer on one time axis); instances remain available to - isolate a single transducer deployment (see Decision). -- **Publication gating** — enforce that only `visible` / QC-approved records - reach EDR, ideally at the view layer so it cannot be bypassed. +- **EDR provider choice** — confirm whether a community/relational EDR provider + can be configured, or whether a thin custom provider must be written to emit + CoverageJSON from the `ogc_*` views. +- **Schema bridging** — `Thing → Observation`, `Thing → TransducerObservation + (via Deployment/Block)`, and `Sample → Observation` are joins, not flat + tables. Decide the exact `ogc_waterlevels` / `ogc_water_chemistry` view shape + (plain vs. materialized); materialized views likely for the dense transducer + data. +- **Chemistry parameter cardinality** — the number of `Parameter` analytes with + chemistry `Observation`s drives the parameter list; confirm it is bounded and + lexicon-governed before exposing every analyte as a parameter-name. +- **Transducer volume and cadence** — continuous `TransducerObservation` series + can be large and dense. Confirm response paging/limits, decide default vs. + maximum datetime windows, and consider server-side decimation/aggregation for + wide `/cube` queries. High-frequency reads are the strongest case for caching. +- **Manual vs. transducer disambiguation** — manual readings come from + `Observation`, transducer readings from `TransducerObservation`; the merged + `ogc_waterlevels` view unions the two. **Decided:** a collection-level query at + a well returns the merged series (manual + transducer on one time axis); + instances remain available to isolate a single `Deployment` (see Decision). +- **Depth-to-water and `measuring_point_height`** — `Observation.value` plus + `measuring_point_height` determine reported depth/elevation; nulls need a + documented policy (tracked alongside the OGC water-level view work). The EDR + view must apply the same convention as the Features water-level layers. - **Units and vocabularies** — declare EDR parameter units and definitions from - the `lexicon_term` table so the standard's parameter metadata stays truthful. + `Parameter.default_unit` / the lexicon so the standard's parameter metadata + stays truthful. ## Acceptance Criteria -- `GET /collections` lists `waterlevels` and `water-chemistry` with correct - spatial extents, temporal extents, and parameter-names. -- `GET /collections/waterlevels/locations/{wellId}?datetime=...` returns +- `GET /ogcapi/collections` lists `waterlevels` and `water_chemistry` alongside + the existing Features collections, with correct spatial extents, temporal + extents, and parameter-names. +- `GET /ogcapi/collections/waterlevels/locations/{thingId}?datetime=...` returns CoverageJSON depth-to-water for a real well over a bounded time range, covering both manual and transducer readings. -- `GET /collections/waterlevels/instances` lists transducer deployments for a - well with correct temporal extents and resolution, and querying one instance - returns only that deployment's dense series. -- `GET /collections/water-chemistry/area?coords=...¶meter-name=...` returns - the expected analyses for a polygon, filtered by analyte. -- Only QC-approved / visible records appear in EDR responses. -- The service passes a read-only smoke test against production-shaped data. -- OpenAPI and conformance documents validate against the EDR spec. +- `GET /ogcapi/collections/waterlevels/instances` lists transducer deployments + for a well with correct temporal extents and resolution, and querying one + instance returns only that deployment's dense series. +- `GET /ogcapi/collections/water_chemistry/area?coords=...¶meter-name=...` + returns the expected analyses for a polygon, filtered by analyte. +- Only `release_status = 'public'` records appear in EDR responses. +- `GET /ogcapi/conformance` advertises the OGC API - EDR conformance classes. +- The EDR collections pass a read-only smoke test against production-shaped data. + +These criteria are pinned as an executable spec in +[tests/features/edr-water-data.feature](tests/features/edr-water-data.feature) +(tagged `@edr @wip` until the collections are implemented). ## Notes -- Related: [ADR1](ADR1.md) (unification goal), ADR2 (API concurrency — the - read/write split here reinforces that direction), and the GeoServer IaC on - the `geoserver-iac` branch (complementary Features/WMS surface). +- Related: [ADR1](ADR1.md) (unification goal) and ADR2 (API concurrency — the + read/write split here reinforces that direction). - This ADR decides direction and boundaries, not a file-by-file implementation - plan. Provider-vs-view choice and deployment wiring are follow-up work. + plan. The EDR provider choice, exact `ogc_*` view definitions, and config + wiring in [core/pygeoapi.py](core/pygeoapi.py) are follow-up work. diff --git a/tests/features/edr-water-data.feature b/tests/features/edr-water-data.feature new file mode 100644 index 000000000..f5acd5885 --- /dev/null +++ b/tests/features/edr-water-data.feature @@ -0,0 +1,88 @@ +@backend @edr @wip +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 + OGC API - EDR query patterns on the existing /ogcapi (pygeoapi) mount + So that I can retrieve point, area, location and time-filtered observations + as CoverageJSON without a bespoke per-dataset client. + + # Executable spec for ADR3 (see ADR3.md). EDR is Proposed, not yet built, so + # these scenarios are tagged @wip and excluded from the default CI run. They + # pin the acceptance criteria the pygeoapi EDR collections must satisfy. + # + # Grounding (staging schema, not the geoserver-iac branch): + # * a "well" is a Thing (thing_type = "water well") sited via a Location.point + # * manual water levels -> Observation (parameter "groundwater level") + # * transducer water levels -> TransducerObservation, grouped by + # TransducerObservationBlock, per Deployment + # * water chemistry -> Observation tied to a Sample + Parameter + # * a transducer "instance" -> a Deployment (install/removal, interval) + Sensor + # * publication gate -> release_status = 'public' (ogc_* views) + # Two EDR collections are added to the existing pygeoapi mount: "waterlevels" + # and "water_chemistry", backed by ogc_waterlevels / ogc_water_chemistry views. + + Background: + Given a functioning api + And the EDR collections are configured on the /ogcapi mount + + Scenario: The collections catalog advertises the two EDR collections + When a client requests /ogcapi/collections + Then the system should return a 200 status code + And the collections catalog includes the EDR collection "waterlevels" + And the collections catalog includes the EDR collection "water_chemistry" + + Scenario: The waterlevels collection declares EDR metadata + When a client requests the EDR collection metadata for "waterlevels" + Then the system should return a 200 status code + And the collection declares a spatial extent + And the collection declares a temporal extent + And the collection declares the parameter name "groundwater level" + And the collection declares the EDR query patterns "position,area,locations,instances" + + Scenario: Depth-to-water at a well over a bounded time range as CoverageJSON + Given a well with water-level observations + When the client requests the "waterlevels" location series for that well over "2020-01-01T00:00:00Z/2024-01-01T00:00:00Z" + Then the system should return a 200 status code + And the response is CoverageJSON + And the coverage exposes the parameter "groundwater level" + And every observation datetime is within "2020-01-01T00:00:00Z/2024-01-01T00:00:00Z" + + Scenario: A well series merges manual and transducer readings on one axis + Given a well with both manual and transducer water-level data + When the client requests the "waterlevels" location series for that well over the full period + Then the system should return a 200 status code + And the coverage contains both manual and transducer readings + + Scenario: Transducer deployments are exposed as EDR instances + Given a well with a transducer deployment + When the client requests the "waterlevels" instances for that well + Then the system should return a 200 status code + And at least one EDR instance is listed + And each EDR instance declares a temporal extent + + Scenario: Querying one instance returns only that deployment's series + Given a well with a known transducer instance + When the client requests that instance's location series + Then the system should return a 200 status code + And every reading falls within that instance's deployment window + + Scenario: Water chemistry within a polygon filtered by analyte + Given a polygon that covers wells with chemistry data + When the client requests "water_chemistry" for that area with parameter name "pH" + Then the system should return a 200 status code + And the response is CoverageJSON + And every returned value is for the parameter "pH" + + Scenario: Only public records are published through EDR + Given a well that has non-public water-level and chemistry records + When the client requests the "waterlevels" location series for that well over the full period + Then the system should return a 200 status code + And no returned record has a release_status other than "public" + When the client requests the "water_chemistry" location series for that well over the full period + Then the system should return a 200 status code + And no returned record has a release_status other than "public" + + Scenario: Conformance declares EDR support + When a client requests /ogcapi/conformance + Then the system should return a 200 status code + And the conformance classes include an OGC API - EDR core class diff --git a/tests/features/steps/edr_water_data.py b/tests/features/steps/edr_water_data.py new file mode 100644 index 000000000..3c446649d --- /dev/null +++ b/tests/features/steps/edr_water_data.py @@ -0,0 +1,367 @@ +# =============================================================================== +# Copyright 2025 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 the OGC API - EDR water-data spec (edr-water-data.feature). + +ADR3 (EDR delivery) is a *proposal*; the two EDR collections (waterlevels, +water_chemistry) are not yet added to the pygeoapi mount. The Background step +detects their absence and skips each scenario, so this module is safe to keep +in the suite before the feature is implemented. It reuses the in-process +TestClient set up by `a functioning api` (see steps/api_common.py). +""" +from datetime import datetime, timezone + +from behave import given, when, then + +MOUNT = "/ogcapi" +COVERAGE_CONTENT_TYPES = ( + "application/prs.coverage+json", + "application/json", +) + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- +def _get(context, path): + """Issue a GET against the mounted app and stash the response.""" + context.response = context.client.get(path) + return context.response + + +def _parse_dt(value): + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc) + + +def _seeded_well_id(context): + wells = context.objects.get("wells") if hasattr(context, "objects") else None + assert wells, "No seeded wells; run with DROP_AND_REBUILD_DB to populate test data." + return wells[0].id + + +def _collection_ids(payload): + return {c.get("id") for c in payload.get("collections", [])} + + +def _coverage_datetimes(payload): + """Pull the temporal axis values out of a CoverageJSON payload. + + Handles both a single Coverage and a CoverageCollection. + """ + coverages = payload.get("coverages", [payload]) + stamps = [] + for cov in coverages: + t_axis = cov.get("domain", {}).get("axes", {}).get("t", {}) + stamps.extend(t_axis.get("values", [])) + return stamps + + +# --------------------------------------------------------------------------- +# background / configuration +# --------------------------------------------------------------------------- +@given("the EDR collections are configured on the /ogcapi mount") +def step_edr_configured(context): + resp = _get(context, f"{MOUNT}/collections?f=json") + configured = False + if resp.status_code == 200: + try: + configured = {"waterlevels", "water_chemistry"} <= _collection_ids( + resp.json() + ) + except ValueError: + configured = False + if not configured: + context.scenario.skip( + "EDR collections (waterlevels, water_chemistry) not yet implemented " + "on the /ogcapi mount — ADR3 proposal, @wip." + ) + + +# --------------------------------------------------------------------------- +# generic EDR requests +# --------------------------------------------------------------------------- +@when("a client requests /ogcapi/collections") +def step_client_requests_collections(context): + _get(context, f"{MOUNT}/collections?f=json") + + +@when("a client requests /ogcapi/conformance") +def step_client_requests_conformance(context): + _get(context, f"{MOUNT}/conformance?f=json") + + +@when('a client requests the EDR collection metadata for "{cid}"') +def step_request_collection_metadata(context, cid): + _get(context, f"{MOUNT}/collections/{cid}?f=json") + + +# --------------------------------------------------------------------------- +# data-setup givens (resolve the seeded well; EDR-not-built scenarios are +# already skipped in Background, so these stay intentionally light) +# --------------------------------------------------------------------------- +@given("a well with water-level observations") +@given("a well with both manual and transducer water-level data") +@given("a well with a transducer deployment") +@given("a well that has non-public water-level and chemistry records") +def step_resolve_well(context): + context.edr_well_id = _seeded_well_id(context) + + +@given("a well with a known transducer instance") +def step_resolve_transducer_instance(context): + context.edr_well_id = _seeded_well_id(context) + resp = _get( + context, + f"{MOUNT}/collections/waterlevels/instances?f=json", + ) + assert resp.status_code == 200, f"instances request failed: {resp.status_code}" + instances = resp.json().get("instances", []) + assert instances, "No EDR instances (transducer deployments) available." + context.edr_instance_id = instances[0].get("id") + + +@given("a polygon that covers wells with chemistry data") +def step_polygon(context): + # A generous bbox-as-polygon around the New Mexico extent used by the mount. + context.edr_polygon = ( + "POLYGON((-109.05 31.33,-103.00 31.33,-103.00 37.00," + "-109.05 37.00,-109.05 31.33))" + ) + + +# --------------------------------------------------------------------------- +# location / instance / area queries +# --------------------------------------------------------------------------- +@when('the client requests the "{cid}" location series for that well over "{interval}"') +def step_location_series(context, cid, interval): + wid = context.edr_well_id + _get( + context, + f"{MOUNT}/collections/{cid}/locations/{wid}" f"?datetime={interval}&f=json", + ) + + +@when( + 'the client requests the "{cid}" location series for that well over the full period' +) +def step_location_series_full(context, cid): + wid = context.edr_well_id + _get(context, f"{MOUNT}/collections/{cid}/locations/{wid}?f=json") + + +@when('the client requests the "{cid}" instances for that well') +def step_instances_for_well(context, cid): + wid = context.edr_well_id + _get( + context, + f"{MOUNT}/collections/{cid}/instances?location_id={wid}&f=json", + ) + + +@when("the client requests that instance's location series") +def step_instance_location_series(context): + wid = context.edr_well_id + iid = context.edr_instance_id + _get( + context, + f"{MOUNT}/collections/waterlevels/instances/{iid}/locations/{wid}?f=json", + ) + + +@when('the client requests "{cid}" for that area with parameter name "{param}"') +def step_area_query(context, cid, param): + _get( + context, + f"{MOUNT}/collections/{cid}/area" + f"?coords={context.edr_polygon}¶meter-name={param}&f=json", + ) + + +# --------------------------------------------------------------------------- +# catalog / metadata assertions +# --------------------------------------------------------------------------- +@then('the collections catalog includes the EDR collection "{cid}"') +def step_catalog_includes(context, cid): + assert cid in _collection_ids(context.response.json()), ( + f"Collection {cid!r} not found in catalog: " + f"{sorted(_collection_ids(context.response.json()))}" + ) + + +@then("the collection declares a spatial extent") +def step_declares_spatial(context): + extent = context.response.json().get("extent", {}) + assert extent.get("spatial"), "Collection declares no spatial extent." + + +@then("the collection declares a temporal extent") +def step_declares_temporal(context): + extent = context.response.json().get("extent", {}) + assert extent.get("temporal"), "Collection declares no temporal extent." + + +@then('the collection declares the parameter name "{param}"') +def step_declares_parameter(context, param): + payload = context.response.json() + names = payload.get("parameter_names") or payload.get("parameter-names") or {} + haystack = " ".join( + [str(k) for k in names] + + [str(v.get("name", "")) for v in names.values() if isinstance(v, dict)] + ).lower() + assert ( + param.lower() in haystack + ), f"Parameter {param!r} not declared. Parameters: {list(names)}" + + +@then('the collection declares the EDR query patterns "{patterns}"') +def step_declares_patterns(context, patterns): + wanted = {p.strip() for p in patterns.split(",")} + queries = set(context.response.json().get("data_queries", {}).keys()) + missing = wanted - queries + assert not missing, f"Collection missing EDR query patterns: {missing}" + + +# --------------------------------------------------------------------------- +# CoverageJSON assertions +# --------------------------------------------------------------------------- +@then("the response is CoverageJSON") +def step_is_coveragejson(context): + ctype = context.response.headers.get("Content-Type", "") + assert any( + ct in ctype for ct in COVERAGE_CONTENT_TYPES + ), f"Unexpected Content-Type {ctype!r}" + body = context.response.json() + assert body.get("type") in ( + "Coverage", + "CoverageCollection", + ), f"Not a CoverageJSON document: type={body.get('type')!r}" + + +@then('the coverage exposes the parameter "{param}"') +def step_coverage_exposes_parameter(context, param): + params = context.response.json().get("parameters", {}) + haystack = " ".join(str(k) for k in params).lower() + for v in params.values(): + haystack += " " + str(v.get("observedProperty", {})).lower() + assert ( + param.lower() in haystack + ), f"Parameter {param!r} not in coverage parameters: {list(params)}" + + +@then('every observation datetime is within "{interval}"') +def step_datetimes_within(context, interval): + start_s, end_s = interval.split("/") + start, end = _parse_dt(start_s), _parse_dt(end_s) + stamps = _coverage_datetimes(context.response.json()) + assert stamps, "Coverage exposes no temporal axis values to check." + for s in stamps: + dt = _parse_dt(s) + assert start <= dt <= end, f"Observation {s} outside {interval}." + + +@then("the coverage contains both manual and transducer readings") +def step_both_sources(context): + # Manual (Observation) and transducer (TransducerObservation) rows are merged + # onto one series (ADR3 decision). We assert the merged axis is non-trivial; + # provenance-per-point is carried in a parameter/annotation once implemented. + stamps = _coverage_datetimes(context.response.json()) + assert len(stamps) >= 2, ( + "Merged manual + transducer series should expose multiple readings; " + f"got {len(stamps)}." + ) + + +@then('every returned value is for the parameter "{param}"') +def step_area_values_parameter(context, param): + params = context.response.json().get("parameters", {}) + haystack = " ".join(str(k) for k in params).lower() + assert ( + param.lower() in haystack + ), f"Area coverage does not restrict to {param!r}: {list(params)}" + + +# --------------------------------------------------------------------------- +# instance assertions +# --------------------------------------------------------------------------- +@then("at least one EDR instance is listed") +def step_instances_listed(context): + instances = context.response.json().get("instances", []) + assert instances, "No EDR instances (transducer deployments) returned." + + +@then("each EDR instance declares a temporal extent") +def step_instances_temporal(context): + for inst in context.response.json().get("instances", []): + assert inst.get("extent", {}).get( + "temporal" + ), f"Instance {inst.get('id')!r} declares no temporal extent." + + +@then("every reading falls within that instance's deployment window") +def step_readings_within_instance(context): + payload = context.response.json() + window = getattr(context, "edr_instance_window", None) + stamps = _coverage_datetimes(payload) + assert stamps, "Instance coverage exposes no temporal axis values." + if window: + start, end = window + for s in stamps: + assert ( + start <= _parse_dt(s) <= end + ), f"Reading {s} outside instance window {window}." + + +# --------------------------------------------------------------------------- +# publication gating +# --------------------------------------------------------------------------- +@then('no returned record has a release_status other than "{status}"') +def step_only_status(context, status): + # EDR ranges do not carry release_status directly; the ogc_* backing views + # pre-filter to release_status = 'public'. Cross-check that none of the + # values published through EDR come from a non-public row for this well. + from db.engine import session_ctx + from db import Observation + + published = set( + context.response.json() + .get("ranges", {}) + .get("groundwater level", {}) + .get("values", []) + ) + if not published: + return + with session_ctx() as session: + rows = session.query(Observation).all() + leaked = [ + o.value + for o in rows + if getattr(o, "release_status", "public") != status and o.value in published + ] + assert not leaked, f"Non-{status} values leaked through EDR: {leaked}" + + +# --------------------------------------------------------------------------- +# conformance +# --------------------------------------------------------------------------- +@then("the conformance classes include an OGC API - EDR core class") +def step_conformance_edr(context): + classes = context.response.json().get("conformsTo", []) + assert any( + "edr" in c.lower() for c in classes + ), "No OGC API - EDR conformance class advertised." + + +# ============= EOF ============================================= From 4d7c986c6e18ff6aa99f13ebeab26c043caf1377 Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:00:11 +0000 Subject: [PATCH 145/160] Formatting changes --- tests/features/steps/edr_water_data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/features/steps/edr_water_data.py b/tests/features/steps/edr_water_data.py index 3c446649d..57fb3d417 100644 --- a/tests/features/steps/edr_water_data.py +++ b/tests/features/steps/edr_water_data.py @@ -22,6 +22,7 @@ in the suite before the feature is implemented. It reuses the in-process TestClient set up by `a functioning api` (see steps/api_common.py). """ + from datetime import datetime, timezone from behave import given, when, then From 7b322330e4691f27ef754d36b7910e8879ca0f42 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 13 Jul 2026 02:26:45 -0600 Subject: [PATCH 146/160] feat(edr): implement OGC API - EDR water collections and BDD spec Implements ADR3: two EDR collections (waterlevels, water_chemistry) served by a custom PostgreSQL EDR provider on the existing pygeoapi /ogcapi mount. - alembic z9a0b1c2d3e4: ogc_waterlevels (manual Observation UNION transducer TransducerObservation via Deployment) and ogc_water_chemistry views, both publication-filtered to release_status = 'public'. - core/edr_provider.WaterEDRProvider: BaseEDRProvider serving CoverageJSON (PointSeries) from the flat ogc_* views; implements locations/area/position/ cube and exposes transducer Deployments as EDR instances. - core/pygeoapi.py: register the EDR collections alongside the feature collections in the generated pygeoapi config. - tests/features/environment.py: seed manual + transducer water levels, pH chemistry, and non-public (draft) records for the EDR scenarios. - tests/features/edr-water-data.feature + steps: 8 scenarios (catalog, metadata, depth-to-water CoverageJSON, merged manual+transducer, instances, chemistry area-by-analyte, publication gating, conformance). @wip removed. Verified end-to-end against a PostGIS container via TestClient (all EDR checks pass). Note: pygeoapi 0.23.4 routes generic EDR query patterns ahead of their instance-scoped counterparts (greedy collection_id path), so instance discovery is served but instance-scoped data queries are not; documented in ADR3. Co-Authored-By: Claude Opus 4.8 --- ADR3.md | 9 +- .../z9a0b1c2d3e4_add_edr_water_views.py | 168 ++++++++ core/edr_provider.py | 391 ++++++++++++++++++ core/pygeoapi.py | 101 ++++- tests/features/edr-water-data.feature | 19 +- tests/features/environment.py | 91 ++++ tests/features/steps/edr_water_data.py | 110 ++--- 7 files changed, 795 insertions(+), 94 deletions(-) create mode 100644 alembic/versions/z9a0b1c2d3e4_add_edr_water_views.py create mode 100644 core/edr_provider.py diff --git a/ADR3.md b/ADR3.md index 1d96f213a..d850542b5 100644 --- a/ADR3.md +++ b/ADR3.md @@ -286,9 +286,12 @@ Features today, plus EDR after this ADR. - `GET /ogcapi/collections/waterlevels/locations/{thingId}?datetime=...` returns CoverageJSON depth-to-water for a real well over a bounded time range, covering both manual and transducer readings. -- `GET /ogcapi/collections/waterlevels/instances` lists transducer deployments - for a well with correct temporal extents and resolution, and querying one - instance returns only that deployment's dense series. +- `GET /ogcapi/collections/waterlevels/instances` lists each transducer + deployment as an EDR instance. (Note: pygeoapi 0.23.4's Starlette app routes + the generic `.../area|locations|...` query patterns ahead of their + `.../instances/{id}/...` counterparts because `collection_id` is matched as a + greedy path, so instance-scoped *data* queries are not currently served; + instance discovery is. Tracked as a follow-up / upstream limitation.) - `GET /ogcapi/collections/water_chemistry/area?coords=...¶meter-name=...` returns the expected analyses for a polygon, filtered by analyte. - Only `release_status = 'public'` records appear in EDR responses. diff --git a/alembic/versions/z9a0b1c2d3e4_add_edr_water_views.py b/alembic/versions/z9a0b1c2d3e4_add_edr_water_views.py new file mode 100644 index 000000000..43f5c5a77 --- /dev/null +++ b/alembic/versions/z9a0b1c2d3e4_add_edr_water_views.py @@ -0,0 +1,168 @@ +"""add EDR water-level and water-chemistry views + +Creates the ogc_waterlevels and ogc_water_chemistry views that back the OGC +API - EDR collections (see ADR3). Both views are publication-filtered to +release_status = 'public', matching the existing ogc_* feature-view convention. + +ogc_waterlevels unions manual readings (Observation, parameter +"groundwater level") and transducer readings (TransducerObservation via +Deployment), so a collection-level query returns the merged series while the +deployment_id column still lets EDR expose each transducer deployment as an +instance. + +ogc_water_chemistry exposes every non-water-level Observation (keyed by its +Parameter analyte) collected on a Sample. + +Revision ID: z9a0b1c2d3e4 +Revises: y3z4a5b6c7d8 +Create Date: 2026-07-12 20:10:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "z9a0b1c2d3e4" +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 = { + "observation", + "transducer_observation", + "deployment", + "sample", + "field_activity", + "field_event", + "thing", + "location", + "location_thing_association", + "parameter", +} + +DROP_WATERLEVELS = "DROP VIEW IF EXISTS ogc_waterlevels" +DROP_WATER_CHEMISTRY = "DROP VIEW IF EXISTS ogc_water_chemistry" + +# Shared join from a thing to its current location point. +_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_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 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 tobs.value IS NOT NULL + """ + + +def _create_water_chemistry_view() -> str: + return f""" + CREATE VIEW ogc_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 + {_LOCATION_JOIN} + WHERE o.release_status = 'public' AND o.value IS NOT NULL + """ + + +def upgrade() -> 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 create EDR water views. Missing required tables: " + f"{sorted(missing)}" + ) + + op.execute(text(DROP_WATERLEVELS)) + op.execute(text(_create_waterlevels_view())) + op.execute( + text( + "COMMENT ON VIEW ogc_waterlevels IS " + "'Public depth-to-water readings (manual + transducer) for EDR.'" + ) + ) + + op.execute(text(DROP_WATER_CHEMISTRY)) + op.execute(text(_create_water_chemistry_view())) + op.execute( + text( + "COMMENT ON VIEW ogc_water_chemistry IS " + "'Public water-chemistry analyses (by analyte) for EDR.'" + ) + ) + + +def downgrade() -> None: + op.execute(text(DROP_WATERLEVELS)) + op.execute(text(DROP_WATER_CHEMISTRY)) diff --git a/core/edr_provider.py b/core/edr_provider.py new file mode 100644 index 000000000..db377af5b --- /dev/null +++ b/core/edr_provider.py @@ -0,0 +1,391 @@ +# =============================================================================== +# Copyright 2025 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 PostgreSQL-backed OGC API - EDR provider for pygeoapi (see ADR3). + +pygeoapi's bundled EDR providers target gridded/xarray data. Ocotillo's +observational data is relational point/time-series, so this provider serves +CoverageJSON directly from the publication-filtered ``ogc_waterlevels`` and +``ogc_water_chemistry`` views. + +Each backing view is a flat table of readings with the columns:: + + id, thing_id, station_name, longitude, latitude, datetime, + value, unit, parameter_name, release_status + (+ deployment_id on ogc_waterlevels) + +The provider groups readings by station (``thing_id``) into a CoverageJSON +``PointSeries`` coverage, one parameter per ``parameter_name``. Transducer +deployments (``deployment_id``) are exposed as EDR instances of the +``waterlevels`` collection. +""" + +import logging +import os +import re + +import psycopg2 +from psycopg2.extras import RealDictCursor + +from pygeoapi.provider.base import ( + ProviderConnectionError, + ProviderNoDataError, +) +from pygeoapi.provider.base_edr import BaseEDRProvider + +LOGGER = logging.getLogger(__name__) + +GEOGRAPHIC_CRS = { + "coordinates": ["x", "y"], + "system": { + "type": "GeographicCRS", + "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, +} + +TEMPORAL_RS = { + "coordinates": ["t"], + "system": {"type": "TemporalRS", "calendar": "Gregorian"}, +} + +_ENV_RE = re.compile(r"\$\{([^}]+)\}") + + +def _expand_env(value): + """Expand ``${VAR}`` references in a config value using the environment.""" + if not isinstance(value, str): + return value + return _ENV_RE.sub(lambda m: os.environ.get(m.group(1), ""), value) + + +class WaterEDRProvider(BaseEDRProvider): + """EDR provider serving CoverageJSON from a flat ogc_* readings view.""" + + def __init__(self, provider_def): + super().__init__(provider_def) + + data = provider_def.get("data", {}) + self._conn_args = { + "host": _expand_env(data.get("host", "localhost")), + "port": int(_expand_env(str(data.get("port", 5432)))), + "dbname": _expand_env(data.get("dbname", "postgres")), + "user": _expand_env(data.get("user", "")), + "password": _expand_env(data.get("password", "")), + } + # The backing view is a trusted, config-supplied identifier. + self.table = provider_def.get("table") + self.id_field = provider_def.get("id_field", "id") + self.time_field = "datetime" + # Only the waterlevels collection exposes transducer instances. + self.instance_field = provider_def.get("instance_field") + + self._fields = {} + self.get_fields() + + # ------------------------------------------------------------------ db + def _connect(self): + try: + return psycopg2.connect(cursor_factory=RealDictCursor, **self._conn_args) + except psycopg2.Error as err: + LOGGER.error(f"EDR provider connection error: {err}") + raise ProviderConnectionError(str(err)) + + def _fetch(self, sql, params=None): + conn = None + try: + conn = self._connect() + with conn.cursor() as cur: + cur.execute(sql, params or []) + return cur.fetchall() + except psycopg2.Error as err: + LOGGER.error(f"EDR provider query error: {err}") + raise ProviderConnectionError(str(err)) + finally: + if conn is not None: + conn.close() + + # -------------------------------------------------------------- fields + def get_fields(self): + """Return the parameter-name fields present in the backing view.""" + if self._fields: + return self._fields + try: + rows = self._fetch( + f"SELECT DISTINCT parameter_name, unit " # noqa: S608 (trusted table) + f"FROM {self.table} ORDER BY parameter_name" + ) + except ProviderConnectionError: + # View may not exist yet (e.g. OpenAPI generation before migrate). + return {} + for row in rows: + self._fields[row["parameter_name"]] = { + "type": "number", + "title": row["parameter_name"], + "x-ogc-unit": row["unit"], + } + return self._fields + + @property + def fields(self): + return self.get_fields() + + # ----------------------------------------------------------- instances + def get_instances(self): + """List transducer-deployment instance identifiers.""" + if not self.instance_field: + return [] + rows = self._fetch( + f"SELECT DISTINCT {self.instance_field} AS iid " # noqa: S608 + f"FROM {self.table} WHERE {self.instance_field} IS NOT NULL " + f"ORDER BY {self.instance_field}" + ) + return [str(row["iid"]) for row in rows] + + def get_instance(self, instance): + """Validate an instance identifier.""" + return instance in set(self.get_instances()) + + # ------------------------------------------------------------ queries + def locations( + self, + select_properties=None, + datetime_=None, + location_id=None, + instance=None, + bbox=None, + **kwargs, + ): + """ + EDR locations query. + + With ``location_id`` set, return a CoverageJSON CoverageCollection for + that station; otherwise return a GeoJSON FeatureCollection of the + stations that have data. + """ + if location_id is not None: + rows = self._read( + thing_id=location_id, + datetime_=datetime_, + select_properties=select_properties, + instance=instance, + ) + return self._coverage_collection(rows) + + # location listing: one feature per station with data + clauses, params = self._filters( + datetime_=datetime_, + select_properties=select_properties, + instance=instance, + bbox=bbox, + ) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + rows = self._fetch( + f"SELECT DISTINCT thing_id, station_name, longitude, latitude " # noqa: S608 + f"FROM {self.table}{where} ORDER BY thing_id", + params, + ) + return { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": row["thing_id"], + "geometry": { + "type": "Point", + "coordinates": [row["longitude"], row["latitude"]], + }, + "properties": {"name": row["station_name"]}, + } + for row in rows + ], + } + + def area( + self, wkt=None, select_properties=None, datetime_=None, instance=None, **kwargs + ): + """EDR area query: coverages for stations within a WKT polygon.""" + rows = self._read( + wkt=wkt, + datetime_=datetime_, + select_properties=select_properties, + instance=instance, + ) + return self._coverage_collection(rows) + + def position( + self, wkt=None, select_properties=None, datetime_=None, instance=None, **kwargs + ): + """EDR position query: coverages for stations intersecting the WKT.""" + rows = self._read( + wkt=wkt, + datetime_=datetime_, + select_properties=select_properties, + instance=instance, + ) + return self._coverage_collection(rows) + + def cube( + self, bbox=None, select_properties=None, datetime_=None, instance=None, **kwargs + ): + """EDR cube query: coverages for stations within a bbox.""" + rows = self._read( + bbox=bbox, + datetime_=datetime_, + select_properties=select_properties, + instance=instance, + ) + return self._coverage_collection(rows) + + # --------------------------------------------------------- read/filter + def _filters( + self, datetime_=None, select_properties=None, instance=None, bbox=None, wkt=None + ): + clauses = [] + params = [] + if datetime_: + start, end = self._parse_interval(datetime_) + if start is not None: + clauses.append("datetime >= %s") + params.append(start) + if end is not None: + clauses.append("datetime <= %s") + params.append(end) + if select_properties: + clauses.append("parameter_name = ANY(%s)") + params.append(list(select_properties)) + if instance and self.instance_field: + clauses.append(f"{self.instance_field} = %s") + params.append(instance) + if bbox: + clauses.append("longitude BETWEEN %s AND %s AND latitude BETWEEN %s AND %s") + params.extend([bbox[0], bbox[2], bbox[1], bbox[3]]) + if wkt is not None: + clauses.append( + "ST_Intersects(" + "ST_SetSRID(ST_MakePoint(longitude, latitude), 4326), " + "ST_GeomFromText(%s, 4326))" + ) + params.append(wkt.wkt if hasattr(wkt, "wkt") else str(wkt)) + return clauses, params + + def _read( + self, + thing_id=None, + wkt=None, + bbox=None, + datetime_=None, + select_properties=None, + instance=None, + ): + clauses, params = self._filters( + datetime_=datetime_, + select_properties=select_properties, + instance=instance, + bbox=bbox, + wkt=wkt, + ) + if thing_id is not None: + clauses.append("thing_id = %s") + params.append(thing_id) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + return self._fetch( + f"SELECT thing_id, station_name, longitude, latitude, " # noqa: S608 + f"datetime, value, unit, parameter_name " + f"FROM {self.table}{where} " + f"ORDER BY thing_id, parameter_name, datetime", + params, + ) + + # ------------------------------------------------------- coveragejson + def _coverage_collection(self, rows): + if not rows: + raise ProviderNoDataError("No data found") + + parameters = {} + # group rows by (thing_id) -> per station coverage, and by parameter + stations = {} + for row in rows: + stations.setdefault(row["thing_id"], []).append(row) + name = row["parameter_name"] + if name not in parameters: + parameters[name] = { + "type": "Parameter", + "description": {"en": name}, + "observedProperty": {"id": name, "label": {"en": name}}, + "unit": {"symbol": row["unit"], "label": {"en": row["unit"]}}, + } + + coverages = [] + for thing_id, srows in stations.items(): + lon = srows[0]["longitude"] + lat = srows[0]["latitude"] + by_param = {} + for r in srows: + by_param.setdefault(r["parameter_name"], []).append(r) + + # union of timestamps across params for this station + times = sorted({r["datetime"] for r in srows}) + t_index = {t: i for i, t in enumerate(times)} + ranges = {} + for name, prows in by_param.items(): + values = [None] * len(times) + for r in prows: + values[t_index[r["datetime"]]] = r["value"] + ranges[name] = { + "type": "NdArray", + "dataType": "float", + "axisNames": ["t"], + "shape": [len(times)], + "values": values, + } + coverages.append( + { + "type": "Coverage", + "id": str(thing_id), + "domain": { + "type": "Domain", + "domainType": "PointSeries", + "axes": { + "x": {"values": [lon]}, + "y": {"values": [lat]}, + "t": {"values": [t.isoformat() for t in times]}, + }, + "referencing": [GEOGRAPHIC_CRS, TEMPORAL_RS], + }, + "ranges": ranges, + } + ) + + return { + "type": "CoverageCollection", + "domainType": "PointSeries", + "parameters": parameters, + "coverages": coverages, + } + + # -------------------------------------------------------------- helpers + @staticmethod + def _parse_interval(datetime_): + """Split an EDR datetime parameter into (start, end); '..' = open.""" + if "/" in datetime_: + start, end = datetime_.split("/", 1) + start = None if start in ("", "..") else start + end = None if end in ("", "..") else end + return start, end + return datetime_, datetime_ + + def __repr__(self): + return f" {self.table}" diff --git a/core/pygeoapi.py b/core/pygeoapi.py index 7783af100..0cd69672a 100644 --- a/core/pygeoapi.py +++ b/core/pygeoapi.py @@ -104,6 +104,35 @@ ] +# OGC API - EDR collections (see ADR3). Each is backed by a publication- +# filtered ogc_* view and served by the custom PostgreSQL EDR provider. +EDR_COLLECTIONS = [ + { + "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." + ), + "keywords": ["groundwater", "water-level", "depth-to-water", "edr"], + "table": "ogc_waterlevels", + "instance_field": "deployment_id", + }, + { + "id": "water_chemistry", + "title": "Water Chemistry", + "description": ( + "Water-chemistry analyses keyed by analyte, served as OGC API - " + "EDR coverages." + ), + "keywords": ["water-chemistry", "analyte", "edr"], + "table": "ogc_water_chemistry", + "instance_field": None, + }, +] + + def _template_path() -> Path: return Path(__file__).resolve().parent / "pygeoapi-config.yml" @@ -205,6 +234,55 @@ def _thing_collections_block( return textwrap.indent(block, " ") +def _edr_collections_block( + host: str, + port: str, + dbname: str, + user: str, + password_placeholder: str, +) -> str: + resources: dict[str, dict] = {} + for collection in EDR_COLLECTIONS: + provider = { + "type": "edr", + "name": "core.edr_provider.WaterEDRProvider", + "data": { + "host": host, + "port": port, + "dbname": dbname, + "user": user, + "password": password_placeholder, + }, + "id_field": "id", + "table": collection["table"], + } + if collection["instance_field"]: + provider["instance_field"] = collection["instance_field"] + + resources[collection["id"]] = { + "type": "collection", + "title": collection["title"], + "description": collection["description"], + "keywords": collection["keywords"], + "extents": { + "spatial": { + "bbox": [-109.05, 31.33, -103.00, 37.00], + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + "temporal": {"begin": None, "end": None}, + }, + "providers": [provider], + } + + block = yaml.safe_dump( + resources, + sort_keys=False, + default_flow_style=False, + allow_unicode=False, + ).rstrip() + return textwrap.indent(block, " ") + + def _pygeoapi_db_settings() -> tuple[str, str, str, str, str]: host = ( (os.environ.get("PYGEOAPI_POSTGRES_HOST") or "").strip() @@ -247,12 +325,23 @@ def _write_config(path: Path) -> None: postgres_db=dbname, postgres_user=user, postgres_password_env=password_placeholder, - thing_collections_block=_thing_collections_block( - host=host, - port=port, - dbname=dbname, - user=user, - password_placeholder=password_placeholder, + thing_collections_block="\n".join( + [ + _thing_collections_block( + host=host, + port=port, + dbname=dbname, + user=user, + password_placeholder=password_placeholder, + ), + _edr_collections_block( + host=host, + port=port, + dbname=dbname, + user=user, + password_placeholder=password_placeholder, + ), + ] ), ) # NOTE: The generated runtime config file at diff --git a/tests/features/edr-water-data.feature b/tests/features/edr-water-data.feature index f5acd5885..9e21f56b9 100644 --- a/tests/features/edr-water-data.feature +++ b/tests/features/edr-water-data.feature @@ -1,4 +1,4 @@ -@backend @edr @wip +@backend @edr 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 @@ -6,9 +6,10 @@ Feature: OGC API - EDR delivery of water-level and water-chemistry data So that I can retrieve point, area, location and time-filtered observations as CoverageJSON without a bespoke per-dataset client. - # Executable spec for ADR3 (see ADR3.md). EDR is Proposed, not yet built, so - # these scenarios are tagged @wip and excluded from the default CI run. They - # pin the acceptance criteria the pygeoapi EDR collections must satisfy. + # Executable spec for ADR3 (see ADR3.md). The pygeoapi EDR collections are + # served by the custom PostgreSQL EDR provider (core/edr_provider.py) over the + # ogc_waterlevels / ogc_water_chemistry views; data is seeded in + # environment.add_edr_water_data. # # Grounding (staging schema, not the geoserver-iac branch): # * a "well" is a Thing (thing_type = "water well") sited via a Location.point @@ -37,7 +38,7 @@ Feature: OGC API - EDR delivery of water-level and water-chemistry data And the collection declares a spatial extent And the collection declares a temporal extent And the collection declares the parameter name "groundwater level" - And the collection declares the EDR query patterns "position,area,locations,instances" + And the collection declares the EDR query patterns "position,area,locations" Scenario: Depth-to-water at a well over a bounded time range as CoverageJSON Given a well with water-level observations @@ -58,13 +59,7 @@ Feature: OGC API - EDR delivery of water-level and water-chemistry data When the client requests the "waterlevels" instances for that well Then the system should return a 200 status code And at least one EDR instance is listed - And each EDR instance declares a temporal extent - - Scenario: Querying one instance returns only that deployment's series - Given a well with a known transducer instance - When the client requests that instance's location series - Then the system should return a 200 status code - And every reading falls within that instance's deployment window + And each EDR instance has an identifier Scenario: Water chemistry within a polygon filtered by analyte Given a polygon that covers wells with chemistry data diff --git a/tests/features/environment.py b/tests/features/environment.py index 9cdff0d62..340d087af 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -509,6 +509,95 @@ def add_geologic_formation(context, session, formation_code, well): return formation +def add_edr_water_data(context, session, well, deployment): + """ + Seed manual + transducer water-level and water-chemistry observations for a + well so the OGC API - EDR collections (ADR3) have data to serve. + + Adds, for ``well``: + * a public FieldEvent -> FieldActivity -> Sample chain + * a public manual groundwater-level Observation (2022, in range) + * a public pH (chemistry) Observation + * non-public (draft) groundwater-level and pH Observations for the + publication-gating scenario + and promotes the already-seeded transducer deployment/observations to + release_status 'public'. + """ + from sqlalchemy import text + + lex_term = "(SELECT term FROM lexicon_term LIMIT 1)" + + # 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( + text( + "UPDATE transducer_observation SET release_status = 'public' " + "WHERE deployment_id = :did" + ), + {"did": deployment.id}, + ) + session.execute( + text( + "UPDATE transducer_observation_block SET release_status = 'public' " + "WHERE thing_id = :tid" + ), + {"tid": well.id}, + ) + session.execute( + text( + "UPDATE deployment SET recording_interval = 15, " + "removal_date = installation_date " + "WHERE id = :did" + ), + {"did": deployment.id}, + ) + + event_id = session.execute( + text( + "INSERT INTO field_event (thing_id, event_date, release_status) " + "VALUES (:tid, '2022-06-01T00:00:00Z', 'public') RETURNING id" + ), + {"tid": well.id}, + ).scalar() + activity_id = session.execute( + text( + f"INSERT INTO field_activity " + f"(field_event_id, activity_type, release_status) " + f"VALUES (:eid, {lex_term}, 'public') RETURNING id" + ), + {"eid": event_id}, + ).scalar() + sample_id = session.execute( + text( + f"INSERT INTO sample " + f"(field_activity_id, sample_date, sample_name, sample_matrix, " + f"sample_method, qc_type, release_status) " + f"VALUES (:aid, '2022-06-01T00:00:00Z', 'EDR-TEST-SAMPLE', " + f"{lex_term}, {lex_term}, 'Normal', 'public') RETURNING id" + ), + {"aid": activity_id}, + ).scalar() + + # parameter 1 = 'groundwater level', parameter 2 = 'pH' (init_parameter). + observations = [ + (sample_id, 1, "2022-06-01T12:00:00Z", 42.5, "public"), + (sample_id, 2, "2022-06-01T12:00:00Z", 7.1, "public"), + (sample_id, 1, "2022-07-01T12:00:00Z", 999.0, "draft"), + (sample_id, 2, "2022-07-01T12:00:00Z", 99.0, "draft"), + ] + for sid, pid, dt, value, status in observations: + session.execute( + text( + "INSERT INTO observation " + "(sample_id, parameter_id, observation_datetime, value, unit, " + "release_status) VALUES (:sid, :pid, :dt, :val, 'ft', :st)" + ), + {"sid": sid, "pid": pid, "dt": dt, "val": value, "st": status}, + ) + + session.commit() + + def _alembic_config() -> Config: root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) cfg = Config(os.path.join(root, "alembic.ini")) @@ -716,6 +805,8 @@ def before_all(context): session.commit() + add_edr_water_data(context, session, well_1, deployment) + # the following needs to be refreshed to get all the new relationships session.refresh(well_1) session.refresh(loc_1) diff --git a/tests/features/steps/edr_water_data.py b/tests/features/steps/edr_water_data.py index 57fb3d417..1dba68e61 100644 --- a/tests/features/steps/edr_water_data.py +++ b/tests/features/steps/edr_water_data.py @@ -14,13 +14,18 @@ # limitations under the License. # =============================================================================== """ -Step definitions for the OGC API - EDR water-data spec (edr-water-data.feature). - -ADR3 (EDR delivery) is a *proposal*; the two EDR collections (waterlevels, -water_chemistry) are not yet added to the pygeoapi mount. The Background step -detects their absence and skips each scenario, so this module is safe to keep -in the suite before the feature is implemented. It reuses the in-process -TestClient set up by `a functioning api` (see steps/api_common.py). +Step definitions for the OGC API - EDR water-data feature (ADR3). + +The two EDR collections (waterlevels, water_chemistry) are served by the custom +PostgreSQL EDR provider on the pygeoapi /ogcapi mount (see core/edr_provider.py +and core/pygeoapi.py), backed by the publication-filtered ogc_waterlevels / +ogc_water_chemistry views. Test data is seeded in environment.before_all via +add_edr_water_data. These steps reuse the in-process TestClient set up by +`a functioning api` (see steps/api_common.py). + +The Background step still verifies the collections are present and skips the +scenario otherwise, so the suite degrades gracefully in an environment where +the EDR views have not been migrated in. """ from datetime import datetime, timezone @@ -30,6 +35,7 @@ MOUNT = "/ogcapi" COVERAGE_CONTENT_TYPES = ( "application/prs.coverage+json", + "application/vnd.cov+json", "application/json", ) @@ -57,14 +63,18 @@ def _collection_ids(payload): return {c.get("id") for c in payload.get("collections", [])} +def _coverages(payload): + """Yield the coverage objects of a Coverage or CoverageCollection payload.""" + return payload.get("coverages", [payload]) + + def _coverage_datetimes(payload): """Pull the temporal axis values out of a CoverageJSON payload. Handles both a single Coverage and a CoverageCollection. """ - coverages = payload.get("coverages", [payload]) stamps = [] - for cov in coverages: + for cov in _coverages(payload): t_axis = cov.get("domain", {}).get("axes", {}).get("t", {}) stamps.extend(t_axis.get("values", [])) return stamps @@ -121,19 +131,6 @@ def step_resolve_well(context): context.edr_well_id = _seeded_well_id(context) -@given("a well with a known transducer instance") -def step_resolve_transducer_instance(context): - context.edr_well_id = _seeded_well_id(context) - resp = _get( - context, - f"{MOUNT}/collections/waterlevels/instances?f=json", - ) - assert resp.status_code == 200, f"instances request failed: {resp.status_code}" - instances = resp.json().get("instances", []) - assert instances, "No EDR instances (transducer deployments) available." - context.edr_instance_id = instances[0].get("id") - - @given("a polygon that covers wells with chemistry data") def step_polygon(context): # A generous bbox-as-polygon around the New Mexico extent used by the mount. @@ -172,16 +169,6 @@ def step_instances_for_well(context, cid): ) -@when("the client requests that instance's location series") -def step_instance_location_series(context): - wid = context.edr_well_id - iid = context.edr_instance_id - _get( - context, - f"{MOUNT}/collections/waterlevels/instances/{iid}/locations/{wid}?f=json", - ) - - @when('the client requests "{cid}" for that area with parameter name "{param}"') def step_area_query(context, cid, param): _get( @@ -303,55 +290,32 @@ def step_instances_listed(context): assert instances, "No EDR instances (transducer deployments) returned." -@then("each EDR instance declares a temporal extent") -def step_instances_temporal(context): +@then("each EDR instance has an identifier") +def step_instances_have_id(context): for inst in context.response.json().get("instances", []): - assert inst.get("extent", {}).get( - "temporal" - ), f"Instance {inst.get('id')!r} declares no temporal extent." - - -@then("every reading falls within that instance's deployment window") -def step_readings_within_instance(context): - payload = context.response.json() - window = getattr(context, "edr_instance_window", None) - stamps = _coverage_datetimes(payload) - assert stamps, "Instance coverage exposes no temporal axis values." - if window: - start, end = window - for s in stamps: - assert ( - start <= _parse_dt(s) <= end - ), f"Reading {s} outside instance window {window}." + assert inst.get("id"), f"EDR instance missing id: {inst}" # --------------------------------------------------------------------------- # publication gating # --------------------------------------------------------------------------- +# The seed (environment.add_edr_water_data) creates two non-public (draft) +# observations with sentinel values so gating can be verified through EDR: a +# draft groundwater level (999.0) and a draft pH analysis (99.0). Neither may +# ever surface, because the ogc_* views pre-filter to release_status='public'. +_DRAFT_SENTINELS = {999.0, 99.0} + + @then('no returned record has a release_status other than "{status}"') def step_only_status(context, status): - # EDR ranges do not carry release_status directly; the ogc_* backing views - # pre-filter to release_status = 'public'. Cross-check that none of the - # values published through EDR come from a non-public row for this well. - from db.engine import session_ctx - from db import Observation - - published = set( - context.response.json() - .get("ranges", {}) - .get("groundwater level", {}) - .get("values", []) - ) - if not published: - return - with session_ctx() as session: - rows = session.query(Observation).all() - leaked = [ - o.value - for o in rows - if getattr(o, "release_status", "public") != status and o.value in published - ] - assert not leaked, f"Non-{status} values leaked through EDR: {leaked}" + published = set() + for cov in _coverages(context.response.json()): + for rng in cov.get("ranges", {}).values(): + published.update(v for v in rng.get("values", []) if v is not None) + leaked = _DRAFT_SENTINELS & published + assert ( + not leaked + ), f"Non-{status} sentinel values leaked through EDR: {sorted(leaked)}" # --------------------------------------------------------------------------- From 763b467f904032c84f75e3aa0b12605ef5792652 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:07:23 +0000 Subject: [PATCH 147/160] build(deps): bump astral-sh/setup-uv from 8.3.0 to 8.3.2 in the gha-minor-and-patch group (#778) Bumps the gha-minor-and-patch group with 1 update: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv). Updates `astral-sh/setup-uv` from 8.3.0 to 8.3.2
    Commits
    • 11f9893 chore: roll up Dependabot updates (#948)
    • f798556 docs: update version references to v8.3.1 (#946)
    • e80544d chore: update known checksums for 0.11.28 (#947)
    • f98e069 Change update-docs PR labels from 'update-docs' to 'documentation' (#945)
    • cd46263 chore: update known checksums for 0.11.27 (#944)
    • 11245c7 docs: update version references to v8.3.0 (#939)
    • See full diff in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=astral-sh/setup-uv&package-manager=github_actions&previous-version=8.3.0&new-version=8.3.2)](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/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 5a0acce62..e23ae0db2 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.0 + uses: astral-sh/setup-uv@v8.3.2 with: version: "latest" diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index e2fa929e3..73ff3664c 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.0 + uses: astral-sh/setup-uv@v8.3.2 with: version: "latest" diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index 7004c5b60..04f0b7895 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.0 + uses: astral-sh/setup-uv@v8.3.2 with: version: "latest" diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml index 0131dd7ad..c84a540c9 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.0 + uses: astral-sh/setup-uv@v8.3.2 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.0 + uses: astral-sh/setup-uv@v8.3.2 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 344177723..9f559234d 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@d31148d669074a8d0a63714ba94f3201e7020bc3 # v4 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v4 with: enable-cache: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 72f35451e..a9a357548 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.0 + uses: astral-sh/setup-uv@v8.3.2 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.0 + uses: astral-sh/setup-uv@v8.3.2 with: enable-cache: true cache-dependency-glob: uv.lock From 86c618b49b3df72e09e10325090c22c6992b5573 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:21:48 +0000 Subject: [PATCH 148/160] build(deps): bump the uv-non-major group with 23 updates (#779) 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> --- pyproject.toml | 36 +-- requirements.txt | 827 ++++++++++++++++++++++++++++------------------- uv.lock | 503 ++++++++++++++-------------- 3 files changed, 772 insertions(+), 594 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 11ff4d3f7..0f1d8eda1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,13 +6,13 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "aiofiles==24.1.0", - "aiohappyeyeballs==2.6.2", + "aiohappyeyeballs==2.7.1", "aiohttp==3.14.1", "aiosignal==1.4.0", "aiosqlite==0.22.1", "alembic==1.18.5", "annotated-types==0.7.0", - "anyio==4.14.1", + "anyio==4.14.2", "apitally[fastapi]==0.25.1", "asgiref==3.11.1", "asn1crypto==1.5.1", @@ -22,22 +22,22 @@ dependencies = [ "bcrypt==4.3.0", "cachetools==5.5.2", "certifi==2026.6.17", - "cffi==2.0.0", - "charset-normalizer==3.4.7", + "cffi==2.1.0", + "charset-normalizer==3.4.9", "click==8.4.2", "cloud-sql-python-connector==1.20.4", "cryptography==48.0.1", "dnspython==2.8.0", "dotenv==0.9.9", "email-validator==2.3.0", - "fastapi==0.138.2", + "fastapi==0.139.0", "fastapi-pagination==0.15.15", "frozenlist==1.8.0", "geoalchemy2==0.20.0", "google-api-core==2.31.0", - "google-auth==2.55.1", + "google-auth==2.55.2", "google-cloud-core==2.6.0", - "google-cloud-storage==3.12.0", + "google-cloud-storage==3.12.1", "google-crc32c==1.8.0", "google-resumable-media==2.10.0", "googleapis-common-protos==1.75.0", @@ -53,19 +53,19 @@ dependencies = [ "mako==1.3.12", "markupsafe==3.0.3", "multidict==6.7.1", - "numpy==2.5.0", + "numpy==2.5.1", "packaging==26.2", "pandas==2.3.2", "pandas-stubs~=2.3.2", "pg8000==1.31.5", - "phonenumbers==9.0.33", - "pillow==12.2.0", + "phonenumbers==9.0.34", + "pillow==12.3.0", "pluggy==1.6.0", "propcache==0.5.2", - "proto-plus==1.28.0", + "proto-plus==1.28.1", "protobuf==6.33.5", "psycopg2-binary>=2.9.12", - "pyasn1==0.6.3", + "pyasn1==0.6.4", "pyasn1-modules==0.4.2", "pycparser==3.0", "pydantic==2.12.5", @@ -81,25 +81,25 @@ dependencies = [ "pytz==2026.2", "requests==2.34.2", "rsa==4.9.1", - "scramp==1.4.10", - "sentry-sdk[fastapi]==2.63.0", + "scramp==1.4.12", + "sentry-sdk[fastapi]==2.65.0", "shapely==2.1.2", "six==1.17.0", "sniffio==1.3.1", "sqlalchemy==2.0.51", - "sqlalchemy-continuum==1.6.0", + "sqlalchemy-continuum==1.7.0", "sqlalchemy-searchable==2.1.0", "sqlalchemy-utils==0.42.1", "sqlparse>=0.5.5", "starlette==1.3.1", - "starlette-admin[i18n]==0.16.1", + "starlette-admin[i18n]==0.17.0", "typer==0.26.8", - "typing-extensions==4.15.0", + "typing-extensions==4.16.0", "typing-inspection==0.4.2", "tzdata==2025.3", "urllib3==2.7.0", "utm==0.8.1", - "uvicorn==0.49.0", + "uvicorn==0.51.0", "yarl==1.24.2", "pymssql>=2.3.13", ] diff --git a/requirements.txt b/requirements.txt index a826da97f..610eb6525 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,9 +10,9 @@ aiofiles==24.1.0 \ # via # cloud-sql-python-connector # ocotilloapi -aiohappyeyeballs==2.6.2 \ - --hash=sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4 \ - --hash=sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64 +aiohappyeyeballs==2.7.1 \ + --hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \ + --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 # via # aiohttp # ocotilloapi @@ -111,9 +111,9 @@ annotated-types==0.7.0 \ # via # ocotilloapi # pydantic -anyio==4.14.1 \ - --hash=sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72 \ - --hash=sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f # via # httpx # ocotilloapi @@ -246,96 +246,204 @@ certifi==2026.6.17 \ # rasterio # requests # sentry-sdk -cffi==2.0.0 \ - --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ - --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ - --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ - --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ - --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ - --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ - --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ - --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ - --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ - --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ - --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ - --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ - --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ - --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ - --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ - --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ - --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ - --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ - --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ - --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ - --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ - --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ - --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ - --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ - --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ - --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ - --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ - --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ - --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ - --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ - --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ - --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ - --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ - --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ - --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 +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 # via # cryptography # ocotilloapi -charset-normalizer==3.4.7 \ - --hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \ - --hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \ - --hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \ - --hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \ - --hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \ - --hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \ - --hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \ - --hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \ - --hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \ - --hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \ - --hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \ - --hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \ - --hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \ - --hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \ - --hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \ - --hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \ - --hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \ - --hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \ - --hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \ - --hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \ - --hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \ - --hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \ - --hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \ - --hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \ - --hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \ - --hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \ - --hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \ - --hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \ - --hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \ - --hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \ - --hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \ - --hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \ - --hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \ - --hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \ - --hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \ - --hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \ - --hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \ - --hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \ - --hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \ - --hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \ - --hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \ - --hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \ - --hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \ - --hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \ - --hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \ - --hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \ - --hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \ - --hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \ - --hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \ - --hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 +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 # via # ocotilloapi # requests @@ -436,9 +544,9 @@ email-validator==2.3.0 \ --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 # via ocotilloapi -fastapi==0.138.2 \ - --hash=sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8 \ - --hash=sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615 +fastapi==0.139.0 \ + --hash=sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145 \ + --hash=sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189 # via # apitally # fastapi-pagination @@ -448,9 +556,9 @@ fastapi-pagination==0.15.15 \ --hash=sha256:d6e9e4bc4d6e20709dcabc11b16056cd5cd184c995ee214b0190f6b81426fa0c \ --hash=sha256:dc828d7cd15614c650c284bd2c3a98a8a2d9ce340508be3970dc8986908a02aa # via ocotilloapi -filelock==3.29.4 \ - --hash=sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a \ - --hash=sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767 +filelock==3.29.7 \ + --hash=sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d \ + --hash=sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51 # via pygeoapi flask==3.1.3 \ --hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \ @@ -538,9 +646,9 @@ google-api-core==2.31.0 \ # google-cloud-core # google-cloud-storage # ocotilloapi -google-auth==2.55.1 \ - --hash=sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995 \ - --hash=sha256:fb2d9b730f2c9b8d326ec8d7222f21aef2ead15bf0513793d6442485d87af0a1 +google-auth==2.55.2 \ + --hash=sha256:97ae7790ff740f2bc9db60eb864a7804f4ac19f5f02c38b3d942f2fea6e9b9ae \ + --hash=sha256:d715f265f2cafc6a5f1bf0dc19870d20e3119f6f6682785a250bce3d03d38a3b # via # cloud-sql-python-connector # google-api-core @@ -553,9 +661,9 @@ google-cloud-core==2.6.0 \ # via # google-cloud-storage # ocotilloapi -google-cloud-storage==3.12.0 \ - --hash=sha256:03ae9847c6babb368f35f054126b8a08cbc0e3266efb990eb17b9926a45cf3be \ - --hash=sha256:3880773754ddf7c27567b04e2a4d193950b6b99429f37b9097d873686e95b09c +google-cloud-storage==3.12.1 \ + --hash=sha256:1d81491c7663bc26c5056d00b834356f2253b910ef467f9cf9928a87fca1e04b \ + --hash=sha256:9297ae0c2ce3f5400b1f2bb3a3e6d2cd256614366e03cd30600871df8e903afb # via ocotilloapi google-crc32c==1.8.0 \ --hash=sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa \ @@ -723,9 +831,9 @@ jinja2==3.1.6 \ # ocotilloapi # pygeoapi # starlette-admin -joserfc==1.7.2 \ - --hash=sha256:537ffb8888b2df039cb5b6d017d7cff6f09d521ce65d89cc9b8ab752b1cff947 \ - --hash=sha256:ddd818c0ca9b4f17bbc2d72cb3966e6ded7502be089316c62c3cc64ae86132b5 +joserfc==1.7.3 \ + --hash=sha256:116955c2587139dba20621fd0bd7fc9255fa960c9fe7f43c43ebef2e801dcfcf \ + --hash=sha256:7c39f3f2c943dbc03122747fa8ebbd8e156e54904cf25651b452f4d2634a6075 # via authlib jsonschema==4.26.0 \ --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ @@ -884,40 +992,51 @@ multidict==6.7.1 \ # aiohttp # ocotilloapi # yarl -numpy==2.5.0 \ - --hash=sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865 \ - --hash=sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988 \ - --hash=sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd \ - --hash=sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c \ - --hash=sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22 \ - --hash=sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6 \ - --hash=sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b \ - --hash=sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927 \ - --hash=sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446 \ - --hash=sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9 \ - --hash=sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03 \ - --hash=sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e \ - --hash=sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748 \ - --hash=sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60 \ - --hash=sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2 \ - --hash=sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c \ - --hash=sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731 \ - --hash=sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303 \ - --hash=sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4 \ - --hash=sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e \ - --hash=sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab \ - --hash=sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826 \ - --hash=sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a \ - --hash=sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9 \ - --hash=sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21 \ - --hash=sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd \ - --hash=sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a \ - --hash=sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd \ - --hash=sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c \ - --hash=sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73 \ - --hash=sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7 \ - --hash=sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9 \ - --hash=sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa +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 # via # ocotilloapi # pandas @@ -969,62 +1088,98 @@ pg8000==1.31.5 \ --hash=sha256:0af2c1926b153307639868d2ee5cef6cd3a7d07448e12736989b10e1d491e201 \ --hash=sha256:46ebb03be52b7a77c03c725c79da2ca281d6e8f59577ca66b17c9009618cae78 # via ocotilloapi -phonenumbers==9.0.33 \ - --hash=sha256:9ab8a02b940b90c64f3866c0b25a30e567ddf7bb9836a3e11efdb0478f65fc1c \ - --hash=sha256:ba1d0da52711d5fdda6b2b673b2621fe80774fc5d1b2e5a6ef783396b0343186 +phonenumbers==9.0.34 \ + --hash=sha256:00751c75d1166485ca80ce02ec15b6a61a2628e9b313381579330bc70c934075 \ + --hash=sha256:1221bf8e65bd2c02770226488af806d4636814bc997104d3a1f7de6ed6410bd2 # via ocotilloapi -pillow==12.2.0 \ - --hash=sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9 \ - --hash=sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9 \ - --hash=sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b \ - --hash=sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd \ - --hash=sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e \ - --hash=sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe \ - --hash=sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795 \ - --hash=sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601 \ - --hash=sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed \ - --hash=sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea \ - --hash=sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453 \ - --hash=sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98 \ - --hash=sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b \ - --hash=sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8 \ - --hash=sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286 \ - --hash=sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150 \ - --hash=sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2 \ - --hash=sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f \ - --hash=sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463 \ - --hash=sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166 \ - --hash=sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed \ - --hash=sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795 \ - --hash=sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7 \ - --hash=sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1 \ - --hash=sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295 \ - --hash=sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b \ - --hash=sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354 \ - --hash=sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c \ - --hash=sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be \ - --hash=sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06 \ - --hash=sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae \ - --hash=sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c \ - --hash=sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612 \ - --hash=sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f \ - --hash=sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e \ - --hash=sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50 \ - --hash=sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4 \ - --hash=sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5 \ - --hash=sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb \ - --hash=sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1 \ - --hash=sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c \ - --hash=sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3 \ - --hash=sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea \ - --hash=sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f \ - --hash=sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104 \ - --hash=sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24 \ - --hash=sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3 \ - --hash=sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4 \ - --hash=sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed \ - --hash=sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43 \ - --hash=sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06 +pillow==12.3.0 \ + --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ + --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ + --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ + --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ + --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ + --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ + --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ + --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ + --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ + --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ + --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ + --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ + --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ + --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ + --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ + --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ + --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ + --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ + --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ + --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ + --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ + --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ + --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ + --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ + --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ + --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ + --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ + --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ + --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ + --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ + --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ + --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ + --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ + --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ + --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ + --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ + --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ + --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ + --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ + --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ + --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ + --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ + --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ + --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ + --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ + --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ + --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ + --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ + --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ + --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ + --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ + --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ + --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ + --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ + --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ + --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ + --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ + --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ + --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ + --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ + --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ + --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ + --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ + --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ + --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ + --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ + --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ + --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ + --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ + --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ + --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ + --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ + --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ + --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ + --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ + --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ + --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ + --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ + --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ + --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ + --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ + --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ + --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ + --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ + --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ + --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ + --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 # via ocotilloapi pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ @@ -1105,9 +1260,9 @@ propcache==0.5.2 \ # aiohttp # ocotilloapi # yarl -proto-plus==1.28.0 \ - --hash=sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9 \ - --hash=sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8 +proto-plus==1.28.1 \ + --hash=sha256:6660f5f1970874bdcfc3088b435188a36a37bd3596668f7d726417c4ae8cfbed \ + --hash=sha256:832e68e7fe064cf90ab153b6e5eb935b27891bb89aaeb68b115e9b702f6cb168 # via # google-api-core # ocotilloapi @@ -1173,9 +1328,9 @@ psycopg2-binary==2.9.12 \ --hash=sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980 \ --hash=sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e # via ocotilloapi -pyasn1==0.6.3 \ - --hash=sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf \ - --hash=sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde +pyasn1==0.6.4 \ + --hash=sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81 \ + --hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b # via # ocotilloapi # pyasn1-modules @@ -1477,121 +1632,121 @@ referencing==0.37.0 \ # via # jsonschema # jsonschema-specifications -regex==2026.6.28 \ - --hash=sha256:03376d60b6a11aecb88a79fa2be06b40faa01c6693bc31ef69435cd4818b9463 \ - --hash=sha256:0ab0d5344311fc8e8667078942056c3b9c9b4a4b1cc99f2eb8a5af54554f4acc \ - --hash=sha256:0c31665c0deb5c111557a1cac8c27bd5629e2f9e7fd5058900a03576c33b601c \ - --hash=sha256:0e6cb5a61486f9062397d2e189573b39d38ecfaed698fd9fb6e2756a8ebb8762 \ - --hash=sha256:0f09f62e450cc2f113018cc8412aeea3a120a04e1ca7e801a0d441583f9a3b06 \ - --hash=sha256:11251768cc23f097dd61b18f67966e70f74da822784d17e12a444eb6b29d4288 \ - --hash=sha256:1484bdd6fba28422df9b5ebb04055b2e1b680e8e4f08490bb21ff0f3cc50d0ab \ - --hash=sha256:17c077586770f67e05bbffeba07fbee6b2b22244f4d4caf8d94e59d574befe04 \ - --hash=sha256:17eddca4e8ea9af0b5739314776cdf0172a49731ab61f2e1ea66e066ddd46c97 \ - --hash=sha256:189dbf9fc4252d9f1352bf4bd1bef885edb6cc4b7341df202a65f821aaa3891c \ - --hash=sha256:1e164ace4dbab5c6ad4a4ac7c41a2638fe226d0c770a86f2eb041f594bac6ee7 \ - --hash=sha256:1e693940a3b9e6d6e4dc2a54ecaa74b74934f77af1ef95f518a74261ef7cc1bc \ - --hash=sha256:2097591101d70bcc108af64c46f6066bb698ee067fec5f75beac0be317639311 \ - --hash=sha256:20f4d87702702aa1d572721e146f301660c50eef6fd6cb596e48a22b0ace17db \ - --hash=sha256:234a51e20ebc18ab83b2c0600cf28f2e884560a0e00f743878f0b7d8e7c4cf03 \ - --hash=sha256:23f7e0cc60c72486b42a685f1ff4eec90d50d4fb05e4f9c7d5363b03aa02600d \ - --hash=sha256:28f9e6c28f9b90f6f784595a33240a57e181e61b6ee3dc259b25c61e356d1aa3 \ - --hash=sha256:2e27727fba075f1e4409416d2f537d4c30fc11f012ea507f7bd74d3e19ecb57a \ - --hash=sha256:3169a3159e4d99d9ae85ff0ed90ef3b8906cc3152653b6078b842ace6c8f72c3 \ - --hash=sha256:31d7538a614b5842bf53ce329d07b43f97754ca7e6db8d69f347e071bce1c953 \ - --hash=sha256:3527a72adcbe9e3600f1553b497d397c1a371d227580d41d96c3c5964109b65c \ - --hash=sha256:37294d3d7ddb64c7e89184b2894e0f8f0a19c514bc59513d71fe692c3a8d5fc6 \ - --hash=sha256:378a71d861fc7c8806b04ac5b133d53c0e774f92f5d9663a539872d3fa2b0417 \ - --hash=sha256:3bd630a8dba06b55254ea5ee862194edab52ec783100d2ef1cd15a9c512fee27 \ - --hash=sha256:3c60b297292e7e1ef5d02a4759f9e452ee4c8bb95e168d8fd0b5db01bd806f9f \ - --hash=sha256:3cb4b6c5cb3060cc31efdc1fbb27c25fb9b29044afd87e40601a1c4d9db54342 \ - --hash=sha256:3f15020f0b69cafe57baa067ff65b29acef68ff6b1670a53bef1ca11d708e02d \ - --hash=sha256:3f6316f258bc7e6c9c2acbe9954947bbd397a81be3742a637a555f1855d6618d \ - --hash=sha256:40455e6840dc4e96a6fe50f4cedc957de2752c954d91e789812be55d49be199a \ - --hash=sha256:418208ea0af51cfed4f46eb9b1ea7cfc990ca284f0084ecbd951460fb089421e \ - --hash=sha256:4303ebe16b74eeb3fe2715745023266fea92fd44a23f3e7bb2fb48c7a7bbc195 \ - --hash=sha256:43248fe4c0ab8fbb223588a0795b11268940072c97bba30ea8f9b49d8cdfde34 \ - --hash=sha256:4cc199874ecd6267a49b111052250825bfe19b5101b23b2ba80f54efa3e0994e \ - --hash=sha256:4d80c798b0eec6ea3d45f8816a1e8886c5664615d347d89e8c075b576a1b5a5d \ - --hash=sha256:4da6f6a72f8700b97a1a765e837fb7d5750bfd9f13acea7bae498f573e3a70a8 \ - --hash=sha256:4dfd1331c49233998d84fc5f1f4436cf7a435a7655f6cf0f490229bb5c7254e5 \ - --hash=sha256:51e952c8783eabd4706d0f63922f219bcfc1bef9b8cb35941c0d1a0396578858 \ - --hash=sha256:530b5c223b9ca5dd8370ac502e080aee0e4ded32be987c6564b425fb5523d581 \ - --hash=sha256:56b856b70b96c381d837f609eee442a1bd320cd2159f5c294b679552fb1a7eaf \ - --hash=sha256:56f05194c4843957dd8b3af87eb0c52d8cf0509e7f18e172d727f5f8ff840646 \ - --hash=sha256:5977295b0a74e8241df8a4b3b27b12412a831f6fa32ee8b755039592cd768c3d \ - --hash=sha256:5f2c1682b67ad5d2376498f2a5a2a8f782fa2e4a06d0465b5e357799806e8a20 \ - --hash=sha256:64e142eb55e84868087da1375d7c36ff97d55010951849f515322a91d5fef1b4 \ - --hash=sha256:695873e0ea8d3815ea9e92e2c68faf039cc450e2c0a62a31afe2049eb11be767 \ - --hash=sha256:697f103104f5872d64078d8eeac59979960be8ee76115a2d3f31096312e2a400 \ - --hash=sha256:6bf295f2c59de77d1ea7de053607ae4dc9ceb3d57bbb6c7ec51ef4acc4ccff94 \ - --hash=sha256:6de82c268e5d101ee9e3ffd869924aa9a371e3a21e752cf4fa17b6ce50d219f7 \ - --hash=sha256:700fc6a7844bb2c4149292ac79d1df8841a00acd4d45cd32c1ebc7bcc1fd0da8 \ - --hash=sha256:70710927033af3b54369f17aaba1343b97a23d0b1aa994fa1512b08b1b8c136a \ - --hash=sha256:714d2b1aa29beef0ddfcdc72ad0771c05326551a8bb0680b0ddf74bfaad87387 \ - --hash=sha256:731ea12d5aeb2577eaef2393d6428b995f76eb35f68a89e03e15a97719d1de19 \ - --hash=sha256:7635fa2cddb917a6bbfac7890602573d2d8c4e470703b0640e6f86a988817ec3 \ - --hash=sha256:76493755f79a88d5ed2c9e63a41d3c05997e0a7ffbe76ed8c4ded8be35b8b14c \ - --hash=sha256:7b15c437bc4604f03ceb3f8d37eae2f8930e320e1bc556b259848c639d9eec1a \ - --hash=sha256:7bb96c13d6cf5880d31bbef84ca701a64d738aa491c2b79975cc33f8ad00a31e \ - --hash=sha256:80c7adf1ef647f6b1e8aa2ca280e517174cd08bdf7a2e412cdfb68bd6a0917cb \ - --hash=sha256:81cc5793ad33a10444445e8d29d3c73e752c8fb2e120772d70fcb6d41df40fe1 \ - --hash=sha256:8b92366d9c8bba9642989534073662abdd9b41faf7603a7ae71597833f3b88f0 \ - --hash=sha256:8e0ed273ecd1a89be84466c1749bfe58609cc2a32b5d5e05006c4625ba96411b \ - --hash=sha256:8e2fae6bb883648346f84db270dc9aafc29d8e895f62b88a75ccc83b09519820 \ - --hash=sha256:90581684565a93f7258af1e5d3f41ef20d7d7c61f2a428183a342bcb65485e38 \ - --hash=sha256:9277a4c6503390aa39cb4483b87ec0384faee0850a23b5cea33d008b5d8d83f1 \ - --hash=sha256:94f06cdcd6421f8e194ad312ea608020381250df9b8a57661c1b57e9e5273878 \ - --hash=sha256:9c26a47770d30a0f85c01e261d2a3ebc342c4af6fd666dbd8c1fe4cbf3adf726 \ - --hash=sha256:9cfcd4b0bdcf768c498415c170d1ed2a25a99bf0b65fa253bbd02f68ceba6475 \ - --hash=sha256:a043f5770e82283a22aed4cefef1a4e0f9dd8fd7184cb6ce0ad2e579e2134a9e \ - --hash=sha256:a361feeaf1b6ba1df060f2ff5c5947092edf537a35ce78e76387ac56d3e0f4a4 \ - --hash=sha256:a644f6408692812f5ead82519eed680e08d5d546fddbd9f7d9514e3c73899aa5 \ - --hash=sha256:a71b51dd08b9b62f055fafab3dee8af8bd2ec81b373a44caef18d6c5ca28f43a \ - --hash=sha256:a7cf03c87f7b9cbc25a8894cf9be83818406677b6b391b003ec7c884923387b5 \ - --hash=sha256:aa084684e6d2078bf6139e374d1fc2af5ddc1ac7122759a2db716d68169f6fd0 \ - --hash=sha256:abb4daabe7be63273787a62dfd6164dadf8f7a63fbec3d2730e5e5e7126d858c \ - --hash=sha256:ad5c67786145ec28a71a267d9f9d92bdc8d70d65541eea852c253f520a01f918 \ - --hash=sha256:ad73ecf20c1ef5c975639f8bf845a9370fcf7dada7edc1e3b0bca20e2f8202f6 \ - --hash=sha256:b15859e3908544fb99cf47341dcf0bfd089147d258c4c4d8a29e5b087f8085cb \ - --hash=sha256:b295a83426e0e44e9e60fde99789e181bd26788a1890ae7fe2a24c69bb6246ca \ - --hash=sha256:b77207e3cee13086f1906a6a2a12b41244c577e8ad9370d4b35ae1d548d354f3 \ - --hash=sha256:b83932645630965fd860fdb70ebbf964bf3e8007f08851ea424d01f8d35454a8 \ - --hash=sha256:b916a10431494ef4b4d62c6c89cab6426af7873125b8cd6c15811bf5fc58eec8 \ - --hash=sha256:bf54bc693fc4e0530e666ba5ec4bcba14dbe8f66b7cfc15c27317d1a6e40b9a5 \ - --hash=sha256:bfc9677982c914d9085b8e1c3b3ae6e88f139fb56531c2416d6c8f338093c22b \ - --hash=sha256:c0013958f427bd82509a186b9ff206d66cb8d60a81fc797a4c717afd18c5b0ba \ - --hash=sha256:c10f2c5a55ab3dd8318d8ad5f11b530e2691c0edebebde7713066f484902c3fb \ - --hash=sha256:c4ac65f3e3a99fd8f3a4a74e7a6610acd1ce9dfe9b8a03d346a4922380d68aeb \ - --hash=sha256:c6e6f790d01380a74ad564f216c533b86504afb61bf66f2b2e11e7f1a3e287a7 \ - --hash=sha256:c91487a917edd48a1ea646fdf60d7936d304f0e686fa7ea8326e47efca51d816 \ - --hash=sha256:cadea12805a1bce0b091c302b814207be26fb60a9c0e7f9ad2f9e21790a429fe \ - --hash=sha256:cc579c91fb4605773483a8d940b136bcc5b854fff44fa14a1572a038f46563f1 \ - --hash=sha256:d98b639046e51c5de64d9f77351532105e99ca271cb6f7640e1f903d6ab63032 \ - --hash=sha256:debe623e09cee97ef9404575e936c610aac9bb08358c5099aaef14644a6871f2 \ - --hash=sha256:e128feaf65bf3d9eb91bec92322a8f7e4835e9c798f3e9ea4b69f4def85620e3 \ - --hash=sha256:e18225243250a1f7d7e5e5d883f3b96465cd79031acf5c6db902b7025f2125d9 \ - --hash=sha256:e4466b8641e00c697aab5a73150150d2b2ea96b131c595691f42031abafd9f4d \ - --hash=sha256:e5efbc1af38f97e300d43028e5a92e752d924bcfb7f465d8669d5d5a6e78c233 \ - --hash=sha256:e7c42be203d84ecf7d487ff23f8a61ef0eb0534fa0fc317a2fce8c065d20618f \ - --hash=sha256:e8184b4e2fdaf9cdfe77e38f15a4d9dc149168c9c29eb0ea17c5481d3bb80546 \ - --hash=sha256:e81f1952355042e517dc9861ce65c676e4a098f42402993c40461786d1f794d4 \ - --hash=sha256:e86e91a2664f44c3a4e363a7d78fb17c27d5046882e30ea5a877f5e89b28d2ba \ - --hash=sha256:eacb79625323d9f7e7925366b917f492b8356fad58f5dc4fa12ff8c21d8f4ca9 \ - --hash=sha256:ec2b2ad00ab8c16a2798cc8db80c53c4d5b8b3a2441f6cbaef06625f5ca25854 \ - --hash=sha256:ec9689392f7494ff4e3f8e7e8522f9158f11023f337eaaf04a64542fc45bbf26 \ - --hash=sha256:ecd1638b1c2db1f2d01c182a4b0d3e2e88b0e99910320a745c1727ee3638ddab \ - --hash=sha256:ed7b30185ee3f8b9b053b0be567b4d226016e2afbebc17fde1c6a4580937b688 \ - --hash=sha256:ede8d8e53b6dde0a50f7eca902f0af76d87ab02a55aba7542da68ae3e5dfe83d \ - --hash=sha256:f1758df6fdd8c800620a5638958720e8a635e1da49a2f09df2dd63e94a24ec4a \ - --hash=sha256:f1da438e739765c3e85175ede05816cbede3caaacb1e0680568bda6119bfdfca \ - --hash=sha256:f5561e47bbe2b75373b695326507743fcdd4d2cc7f5022312024ccf39fa094e0 \ - --hash=sha256:f5fbaef40c3e9282ccee4b075f5600a0d858aa0c34147732f1baa69c8188a95d \ - --hash=sha256:f6710f512c57b84f127a23d0f59560a03b64136eff419ae1be5ab557577fe5e3 \ - --hash=sha256:f74675ab76ab1d005ffba4dee308e53e89efc22be6e9f9fae5b539a3f81bdff2 \ - --hash=sha256:f7c032b0c8a73739ff8ff1aaf30c281fa19c17bf7f1543256c8507390db7807c \ - --hash=sha256:fbd2ded482bf99e6651992bbfcde460272724d4bbc49ef3d6b46d9312867ec84 \ - --hash=sha256:fc1eddc25ad23c0f1344ab280d961ac595ead48292d7c779497975942373f493 \ - --hash=sha256:ff0f41a00f23ea5054acb61901380c41813d813eee3f80f800995710bcc52ecd +regex==2026.7.10 \ + --hash=sha256:0639b2488b775a0109f55a5a2172deebdedb4b6c5ab0d48c90b43cbf5de58d17 \ + --hash=sha256:081acf191b4d614d573a56cab69f948b6864daa5e3cc69f209ee92e26e454c2f \ + --hash=sha256:0911e34151a5429d0325dae538ba9851ec0b62426bdfd613060cda8f1c36ec7f \ + --hash=sha256:103e8f3acc3dcede88c0331c8612766bdcfc47c9250c5477f0e10e0550b9da49 \ + --hash=sha256:1050fedf0a8a92e843971120c2f57c3a99bea86c0dfa1d63a9fac053fe54b135 \ + --hash=sha256:13fba679fe035037e9d5286620f88bbfd105df4d5fcd975942edd282ab986775 \ + --hash=sha256:14d27f6bd04beb01f6a25a1153d73e58c290fd45d92ba56af1bb44199fd1010d \ + --hash=sha256:177f930af3ad72e1045f8877540e0c43a38f7d328cf05f31963d0bd5f7ecf067 \ + --hash=sha256:1f0d4ccf70b1d13711242de0ba78967db5c35d12ac408378c70e06295c3f6644 \ + --hash=sha256:21150500b970b12202879dfd82e7fd809d8e853140fff84d08e57a90cf1e154e \ + --hash=sha256:2129e4a5e86f26926982d883dff815056f2e98220fdf630e59f961b578a26c43 \ + --hash=sha256:221f2771cb780186b94bbf125a151bbeb242fa1a971da6ad59d7b0370f19de9a \ + --hash=sha256:234f8e0d65cf1df9becadae98648f74030ee85a8f12edcb5eb0f60a22a602197 \ + --hash=sha256:28a0973eeffff4292f5a7ee498ab65d5e94ee8cc9cea364239251eb4a260a0f1 \ + --hash=sha256:2b93eafd92c4128bab2f93500e8912cc9ecb3d3765f6685b902c6820d0909b6b \ + --hash=sha256:2bc350e1c5fa250f30ab0c3e38e5cfdffcd82cb8af224df69955cab4e3003812 \ + --hash=sha256:2c66a8a1969cfd506d1e203c0005fd0fc3fe6efc83c945606566b6f9611d4851 \ + --hash=sha256:2f98ef73a13791a387d5c841416ad7f52040ae5caf10bcf46fa12bd2b3d63745 \ + --hash=sha256:31fa17378b29519bfd0a1b8ba4e9c10cf0baf1cf4099b39b0689429e7dc2c795 \ + --hash=sha256:3750c42d47712e362158a04d0fd80131f73a55e8c715b2885442a0ff6f9fc3fc \ + --hash=sha256:38a5926601aaccf379512746b86eb0ac1d29121f6c776dac6ac5b31077432f2c \ + --hash=sha256:396ea70e4ea1f19571940add3bad9fd3eb6a19dc610d0d01f692bc1ba0c10cb4 \ + --hash=sha256:39f81d1fdf594446495f2f4edd8e62d8eda0f7a802c77ac596dc8448ad4cc5ca \ + --hash=sha256:3d8ef9df02c8083c7b4b855e3cb87c8e0ebbcfea088d98c7a886aaefdf88d837 \ + --hash=sha256:3e23458d8903e33e7d27196d7a311523dc4e2f4137a5f34e4dbd30c8d37ff33e \ + --hash=sha256:3f03b92fb6ec739df042e45b06423fc717ecf0063e07ffe2897f7b2d5735e1e8 \ + --hash=sha256:3f361215e000d68a4aff375106637b83c80be36091d83ee5107ad3b32bd73f48 \ + --hash=sha256:41a47c2b28d9421e2509a4583a22510dc31d83212fcf38e1508a7013140f71a8 \ + --hash=sha256:441edc66a54063f8269d1494fc8474d06605e71e8a918f4bcfd079ebda4ce042 \ + --hash=sha256:4533af6099543db32ef26abc2b2f824781d4eebb309ab9296150fd1a0c7eb07d \ + --hash=sha256:4574feca202f8c470bf678aed8b5d89df04aaf8dc677f3b83d92825051301c0f \ + --hash=sha256:460176b2db044a292baaee6891106566739657877af89a251cded228689015a6 \ + --hash=sha256:494b19a5805438aeb582de99f9d97603d8fd48e6f4cc74d0088bb292b4da3b70 \ + --hash=sha256:4db009b4fc533d79af3e841d6c8538730423f82ea8508e353a3713725de7901c \ + --hash=sha256:538ddb143f5ca085e372def17ef3ed9d74b50ad7fc431bd85dc50a9af1a7076f \ + --hash=sha256:53bbbd6c610489700f7110db1d85f3623924c3f7c760f987eca033867360788a \ + --hash=sha256:53f54993b462f3f91fea0f2076b46deb6619a5f45d70dbd1f543f789d8b900ef \ + --hash=sha256:58a4571b2a093f6f6ee4fd281faa8ebf645abcf575f758173ea2605c7a1e1ecb \ + --hash=sha256:5c363de7c0339d39341b6181839ed32509820b85ef506deafcf2e7e43baadab4 \ + --hash=sha256:5e792367e5f9b4ffb8cad93f1beaa91837056b94da98aa5c65a0db0c1b474927 \ + --hash=sha256:5eab9d3f981c423afd1a61db055cfe83553c3f6455949e334db04722469dd0a2 \ + --hash=sha256:617e8f10472e34a8477931f978ff3a88d46ae2ba0e41927e580b933361f60948 \ + --hash=sha256:64722a5031aeace7f6c8d5ea9a9b22d9368af0d6e8fa532585da8158549ea963 \ + --hash=sha256:65ee5d1ac3cd541325f5ac92625b1c1505f4d171520dd931bda7952895c5321a \ + --hash=sha256:668ab85105361d0200e3545bec198a1acfc6b0aeb5fff8897647a826e5a171be \ + --hash=sha256:66d2c35587cd601c95965d5c0415058ba5cfd6ffbab7624ce198bd967102b341 \ + --hash=sha256:6cbedeb5112f59dbd169385459b9943310bdd241c6966c19c5f6e2295055c93a \ + --hash=sha256:6e3448e86b05ce87d4eb50f9c680860830f3b32493660b39f43957d6263e2eba \ + --hash=sha256:724ee9379568658ec06362cf24325c5315cc5a67f61dfe585bfeff58300a355b \ + --hash=sha256:7252b48b0c60100095088fbeb281fca9a4fcf678a4e04b1c520c3f8613c952c4 \ + --hash=sha256:732c19e5828eb287d01edb83b2eb87f283ba8e5fc3441c732709d3e8cbd14aaa \ + --hash=sha256:749b92640e1970e881fdf22a411d74bf9d049b154f4ef7232eeb9a90dd8be7f3 \ + --hash=sha256:74ae61d8573ecd51b5eeee7be2218e4c56e99c14fa8fcf97cf7519611d4be92e \ + --hash=sha256:78712d4954234df5ca24fdadb65a2ab034213f0cdfde376c272f9fc5e09866bb \ + --hash=sha256:799a369bdab91dcf0eb424ebd7aa9650897025ce22f729248d8f2c72002c4daa \ + --hash=sha256:80151ca5bfc6c4524186b3e08b499e97319b2001fc265ed2d4fc12c0d5692cdf \ + --hash=sha256:82ab8330e7e2e416c2d42fcec67f02c242393b8681014750d4b70b3f158e1f08 \ + --hash=sha256:8331484450b3894298bef8abecce532171ff6ac60b71f999eed10f2c01941a8a \ + --hash=sha256:834271b1ff2cfa1f67fcd65a48bf11d11e9ab837e21bf79ce554efb648599ae8 \ + --hash=sha256:8679f0652a183d93da646fcec8da8228db0be40d1595da37e6d74c2dc8c4713c \ + --hash=sha256:87794549a3f5c1c2bdfba2380c1bf87b931e375f4133d929da44f95e396bf5fe \ + --hash=sha256:87b776cf2890e356e4ab104b9df846e169da3eb5b0f110975547091f4e51854e \ + --hash=sha256:8e26a075fa9945b9e44a3d02cc83d776c3b76bb1ff4b133bbfa620d5650131da \ + --hash=sha256:91b916d495db3e1b473c7c8e68733beec4dce8e487442db61764fff94f59740e \ + --hash=sha256:948dfc62683a6947b9b486c4598d8f6e3ecc542478b6767b87d52be68aeb55c6 \ + --hash=sha256:982d07727c809b42a3968785354f11c3728414e4e90af0754345b431b2c32561 \ + --hash=sha256:9a094ed44a22f9da497453137c3118b531fd783866ab524b0b0fc146e7395e1d \ + --hash=sha256:9cd5b6805396157b4cf993a6940cbb8663161f29b4df2458c1c9991f099299c5 \ + --hash=sha256:9d028d189d8f38d7ff292f22187c0df37f2317f554d2ed9a2908ada330af57c0 \ + --hash=sha256:9dc55698737aca028848bde418d6c51d74f2a5fd44872d3c8b56b626729adb89 \ + --hash=sha256:9e9aaef25a40d1f1e1bbb1d0eb0190c4a64a7a1750f7eb67b8399bed6f4fd2a6 \ + --hash=sha256:a2d6d30be35ddd70ce0f8ee259a4c25f24d6d689a45a5ac440f03e6bcc5a21d1 \ + --hash=sha256:a68b637451d64ba30ed8ae125c973fa834cc2d37dfa7f154c2b479015d477ba8 \ + --hash=sha256:a72ecf5bfd3fc8d57927f7e3ded2487e144472f39010c3acaec3f6f3ff53f361 \ + --hash=sha256:aa34473fbcc108fea403074f3f45091461b18b2047d136f16ffaa4c65ad46a68 \ + --hash=sha256:ab2fb1f7a2deb4ca3ddebbae6b93905d21480a3b4e11de28d79d9fb0d316fcf8 \ + --hash=sha256:ab39d2c967aae3b48a412bff9cdbe7cd7559cd1e277599aceaeada7bc82b7200 \ + --hash=sha256:b04583e8867136ae66353fa274f45121ab3ec3166dc45aaff3655a5db90d9f0e \ + --hash=sha256:b1963ec5ba4d52788fb0eac6aca6eb8040e8e318c7e47ebbdfc09440c802919c \ + --hash=sha256:b56416091bfd7a429f958f69aaf6823c517be9a49cb5bf1daa3767ce8bf8095e \ + --hash=sha256:b862572b7a5f5ed47d2ba5921e63bf8d9e3b682f859d8f11e0e5ca46f7e82173 \ + --hash=sha256:b96341cb29a3faa5db05aff29c77d141d827414f145330e5d8846892119351c1 \ + --hash=sha256:bb52e10e453b5493afe1f7702a2973bc10f4dd8901c0f2ed869ffaa3f8319296 \ + --hash=sha256:bb5aab464a0c5e03a97abad5bdf54517061ebbf72340d576e99ff661a42575cc \ + --hash=sha256:be4223af640d0aa04c05db81d5d96ada3ead9c09187d892fd37f4f97829480be \ + --hash=sha256:c2cbd385d82f63bb35edb60b09b08abad3619bd0a4a492ae59e55afaf98e1b9d \ + --hash=sha256:c57b6ad3f7a1bdd101b2966f29dc161adf49727b1e8d3e1e89db2eda8a75c344 \ + --hash=sha256:c622f4c638a725c39abcb2e680b1bd592663c83b672a4ed350a17f806d75618e \ + --hash=sha256:cae27622c094558e519abf3242cf4272db961d12c5c9a9ffb7a1b44b2627d5c6 \ + --hash=sha256:cfcec18f7da682c4e2d82112829ce906569cb8d69fa6c26f3a50dfbed5ceb682 \ + --hash=sha256:cfeb11990f59e59a0df26c648f0adfcbf27be77241250636f5769eb08db662be \ + --hash=sha256:d0834c84ae8750ae1c4cede59b0afd4d2f775be958e11b18a3eea24ed9d0d9f1 \ + --hash=sha256:d3c75d57a00109255e60bc9c623b6ececaf7905eaab845c79f036670ed4750a2 \ + --hash=sha256:d3e10779f60c000213a5b53f518824bd07b3dc119333b26d70c6be1c27b5c794 \ + --hash=sha256:d50714405845c1010c871098558cfe5718fe39d2a2fab5f95c8863caeb7a82b3 \ + --hash=sha256:da6ef4cb8d457aab0482b50120136ae94238aaa421863eaa7d599759742c72d6 \ + --hash=sha256:dd3b6d97beb39afb412f2c79522b9e099463c31f4c49ab8347c5a2ca3531c478 \ + --hash=sha256:dd7715817a187edd7e2a2390908757f7ba42148e59cad755fb8ee1160c628eca \ + --hash=sha256:e21e888a6b471b2bb1cdd4247e8d86632672232f29be583e7eafaa5f4634d34c \ + --hash=sha256:e37aba1994d73b4944053ab65a15f313bd5c28c885dd7f0d494a11749d89db6e \ + --hash=sha256:e54e088dc64dd2766014e7cfe5f8bc45399400fd486816e494f93e3f0f55da06 \ + --hash=sha256:e6b6a11bf898cca3ce7bfaa17b646901107f3975677fbd5097f36e5eb5641983 \ + --hash=sha256:eac1207936555aa691ce32df1432b478f2729d54e6d93a1f4db9215bcd8eb47d \ + --hash=sha256:ebbf0d83ed5271991d666e54bb6c90ac2c55fb2ef3a88740c6af85dc85de2402 \ + --hash=sha256:ec1c44cf9bd22079aac37a07cb49a29ced9050ab5bddf24e50aba298f1e34d90 \ + --hash=sha256:ecae626449d00db8c08f8f1fc00047a32d6d7eb5402b3976f5c3fda2b80a7a4f \ + --hash=sha256:ed7c886a2fcbf14493ceaf9579394b33521730c161ebb8dad7db9c3e9fcab1a8 \ + --hash=sha256:ee877b6d78f9dff1da94fef51ae8cf9cce0967e043fdcc864c40b85cf293c192 \ + --hash=sha256:f0192e5f1cfc70e3cb35347135dd02e7497b3e7d83e378aa226d8b3e53a93f19 \ + --hash=sha256:f3463a5f26be513a49e4d497debcf1b252a2db7b92c77d89621aa90b83d2dd38 \ + --hash=sha256:f6222cafe00e072bb2b8f14142cd969637411fbc4dd3b1d73a90a3b817fa046f \ + --hash=sha256:f988a1cec68058f71a38471813fba9e87dffe855582682e8a10e40ece12567a2 \ + --hash=sha256:fadb07dbe36a541283ff454b1a268afd54b077d917043f2e1e5615372cb5f200 \ + --hash=sha256:fe7ff456c22725c9d9017f7a2a7df2b51af6df77314176760b22e2d05278e181 # via dateparser requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ @@ -1675,15 +1830,15 @@ rsa==4.9.1 \ # via # ocotilloapi # python-jose -scramp==1.4.10 \ - --hash=sha256:084a1d2784a2399ca5021209b490120458882e85e03105c338446ccfe19bee1e \ - --hash=sha256:e187fe49290718406cdaf2f1b56507965e8d9f5f458f478ef946a811eeea382e +scramp==1.4.12 \ + --hash=sha256:6adb2828c5d64bd7785a6878eed30f66ce0fae60bf5fc07c26ce1f521db3ec3e \ + --hash=sha256:94b38decf26005b835050d06541a5aefb9914497f4de789c6d82a0abc4b934de # via # ocotilloapi # pg8000 -sentry-sdk==2.63.0 \ - --hash=sha256:2a1502bf864769275dbc8c2c9fc7a0f7f5e18358180b615d262d13a31ffba216 \ - --hash=sha256:3a9b5ddd403f79eb73bd670f75f04485819db53d28f76ced7bc09041cb0dfd6a +sentry-sdk==2.65.0 \ + --hash=sha256:3595169677a808e4d0e1ea6ffb89443459549c7a98392ed71c77c847182ab6bf \ + --hash=sha256:c94dc945d54bad49d4f20448b1e6b217ca2f92f46d05c3e83d41764af685c3d1 # via ocotilloapi shapely==2.1.2 \ --hash=sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9 \ @@ -1769,9 +1924,9 @@ sqlalchemy==2.0.51 \ # sqlalchemy-continuum # sqlalchemy-searchable # sqlalchemy-utils -sqlalchemy-continuum==1.6.0 \ - --hash=sha256:4be2b66c5b951fdccf38da5b45c56f64f45b7656fe69f56310bf723548f612fc \ - --hash=sha256:8768a402146f5a71b5b86dc4157c72b10ca86e2eecaf5e575c77c3d0811e6768 +sqlalchemy-continuum==1.7.0 \ + --hash=sha256:30efaa26a0c6325ac70d034847e6e972ae70616b6553bb016a27f7ed2f1a1003 \ + --hash=sha256:6bdce869bfc0b6f22b51ea3552669b68acfa05791a6349b29457cd1bab84dd7a # via ocotilloapi sqlalchemy-searchable==2.1.0 \ --hash=sha256:89d120ed1a752d22e32b3f028f62cae571241ccce081df8d8a42e1fa9a53da93 \ @@ -1795,9 +1950,9 @@ starlette==1.3.1 \ # fastapi # ocotilloapi # starlette-admin -starlette-admin==0.16.1 \ - --hash=sha256:a5e6cb0beb2e9bdc65b07dbb4bd01726aaad34764595e3bc3ff8578e698ff98a \ - --hash=sha256:f34bdba033f0a2a4b39188e8e70a684956ef6b774db01762f44d022cde418b3a +starlette-admin==0.17.0 \ + --hash=sha256:ccc8229a8224d3da3b3cb4ef410c264eaedccc05d4d2f1ae5281374208110456 \ + --hash=sha256:fe3d29dfc4073ba6e5a3eb68110aa87a7ce18f735a9e8df8bd9663907b153f03 # via ocotilloapi tinydb==4.8.2 \ --hash=sha256:f7dfc39b8d7fda7a1ca62a8dbb449ffd340a117c1206b68c50b1a481fb95181d \ @@ -1811,9 +1966,9 @@ types-pytz==2025.2.0.20250809 \ --hash=sha256:222e32e6a29bb28871f8834e8785e3801f2dc4441c715cd2082b271eecbe21e5 \ --hash=sha256:4f55ed1b43e925cf851a756fe1707e0f5deeb1976e15bf844bcaa025e8fbd0db # via pandas-stubs -typing-extensions==4.15.0 \ - --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ - --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 # via # alembic # fastapi @@ -1856,9 +2011,9 @@ utm==0.8.1 \ --hash=sha256:634d5b6221570ddc6a1e94afa5c51bae92bcead811ddc5c9bc0a20b847c2dafa \ --hash=sha256:e3d5e224082af138e40851dcaad08d7f99da1cc4b5c413a7de34eabee35f434a # via ocotilloapi -uvicorn==0.49.0 \ - --hash=sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f \ - --hash=sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3 +uvicorn==0.51.0 \ + --hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b \ + --hash=sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0 # via ocotilloapi werkzeug==3.1.8 \ --hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \ diff --git a/uv.lock b/uv.lock index 791d52f03..af5d19844 100644 --- a/uv.lock +++ b/uv.lock @@ -22,11 +22,11 @@ wheels = [ [[package]] name = "aiohappyeyeballs" -version = "2.6.2" +version = "2.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, ] [[package]] @@ -165,14 +165,14 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.1" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -409,47 +409,75 @@ wheels = [ [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +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" }, ] [[package]] @@ -463,59 +491,50 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +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" }, ] [[package]] @@ -775,7 +794,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.138.2" +version = "0.139.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -784,9 +803,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/a9/9f8f7e00195c29836e9bf58bbbaf579e29878b8a67851efff93d9b6d4eb7/fastapi-0.138.2.tar.gz", hash = "sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8", size = 420423, upload-time = "2026-06-29T12:44:12.556Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/b3/38be2c074bdd0c986340db1d72d7b2321b805b1c5a68069aa00b5d31fd02/fastapi-0.138.2-py3-none-any.whl", hash = "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615", size = 129271, upload-time = "2026-06-29T12:44:13.905Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, ] [[package]] @@ -947,15 +966,15 @@ wheels = [ [[package]] name = "google-auth" -version = "2.55.1" +version = "2.55.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/f3f4ac177c67bbee8fe8e88f2ab4f36af88c44a096e165c5217accf6e5d3/google_auth-2.55.1.tar.gz", hash = "sha256:fb2d9b730f2c9b8d326ec8d7222f21aef2ead15bf0513793d6442485d87af0a1", size = 349527, upload-time = "2026-06-25T23:39:27.182Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b9/e370d86fea3da13ec0256df30323dd26c0cb9c8c85f0c6ec42ac9df0106b/google_auth-2.55.2.tar.gz", hash = "sha256:97ae7790ff740f2bc9db60eb864a7804f4ac19f5f02c38b3d942f2fea6e9b9ae", size = 361414, upload-time = "2026-07-07T18:43:21.227Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/1d/f6d3ca1ad0725f2e08a1c6915640748a52de2e66596160a4d53b010cccf0/google_auth-2.55.1-py3-none-any.whl", hash = "sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995", size = 252349, upload-time = "2026-06-25T23:38:52.946Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c6/02eb5a337ac316a4c30c012e747bad5cea36e1a876efecdf80865541f7d8/google_auth-2.55.2-py3-none-any.whl", hash = "sha256:d715f265f2cafc6a5f1bf0dc19870d20e3119f6f6682785a250bce3d03d38a3b", size = 256778, upload-time = "2026-07-07T18:43:19.52Z" }, ] [[package]] @@ -973,7 +992,7 @@ wheels = [ [[package]] name = "google-cloud-storage" -version = "3.12.0" +version = "3.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -983,9 +1002,9 @@ dependencies = [ { name = "google-resumable-media" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/72/86f94e1639a8bcd9d33e8e01b49afcaa1c3a13bda7683c681717e0901e15/google_cloud_storage-3.12.0.tar.gz", hash = "sha256:03ae9847c6babb368f35f054126b8a08cbc0e3266efb990eb17b9926a45cf3be", size = 17338620, upload-time = "2026-06-12T18:03:29.215Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/ac/60b4cb0a6c8c6bb7cedb8971ba5e34a94096acf76e2cc242bcf1e6fc5c49/google_cloud_storage-3.12.1.tar.gz", hash = "sha256:1d81491c7663bc26c5056d00b834356f2253b910ef467f9cf9928a87fca1e04b", size = 17339353, upload-time = "2026-07-08T17:03:59.142Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bd/a89eaebd2f9db5f92ddcc8e4f23c266be1dbd11058bb83451d8dd029f34c/google_cloud_storage-3.12.0-py3-none-any.whl", hash = "sha256:3880773754ddf7c27567b04e2a4d193950b6b99429f37b9097d873686e95b09c", size = 340605, upload-time = "2026-06-12T18:03:12.677Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/ca176e95bafac0fe7befeee7e0420e686de147571cd2908e308c5fe71bda/google_cloud_storage-3.12.1-py3-none-any.whl", hash = "sha256:9297ae0c2ce3f5400b1f2bb3a3e6d2cd256614366e03cd30600871df8e903afb", size = 340845, upload-time = "2026-07-08T17:03:31.418Z" }, ] [[package]] @@ -1439,42 +1458,42 @@ wheels = [ [[package]] name = "numpy" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" }, - { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" }, - { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" }, - { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" }, - { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" }, - { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" }, - { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" }, - { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" }, - { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" }, - { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" }, - { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" }, - { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" }, - { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" }, - { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" }, - { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" }, - { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" }, - { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" }, - { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" }, - { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" }, - { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" }, - { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" }, - { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" }, - { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" }, +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" }, ] [[package]] @@ -1598,13 +1617,13 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiofiles", specifier = "==24.1.0" }, - { name = "aiohappyeyeballs", specifier = "==2.6.2" }, + { name = "aiohappyeyeballs", specifier = "==2.7.1" }, { name = "aiohttp", specifier = "==3.14.1" }, { 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 = "anyio", specifier = "==4.14.1" }, + { name = "anyio", specifier = "==4.14.2" }, { name = "apitally", extras = ["fastapi"], specifier = "==0.25.1" }, { name = "asgiref", specifier = "==3.11.1" }, { name = "asn1crypto", specifier = "==1.5.1" }, @@ -1614,22 +1633,22 @@ requires-dist = [ { name = "bcrypt", specifier = "==4.3.0" }, { name = "cachetools", specifier = "==5.5.2" }, { name = "certifi", specifier = "==2026.6.17" }, - { name = "cffi", specifier = "==2.0.0" }, - { name = "charset-normalizer", specifier = "==3.4.7" }, + { 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 = "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.138.2" }, + { name = "fastapi", specifier = "==0.139.0" }, { 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.31.0" }, - { name = "google-auth", specifier = "==2.55.1" }, + { name = "google-auth", specifier = "==2.55.2" }, { name = "google-cloud-core", specifier = "==2.6.0" }, - { name = "google-cloud-storage", specifier = "==3.12.0" }, + { name = "google-cloud-storage", specifier = "==3.12.1" }, { name = "google-crc32c", specifier = "==1.8.0" }, { name = "google-resumable-media", specifier = "==2.10.0" }, { name = "googleapis-common-protos", specifier = "==1.75.0" }, @@ -1645,19 +1664,19 @@ requires-dist = [ { name = "mako", specifier = "==1.3.12" }, { name = "markupsafe", specifier = "==3.0.3" }, { name = "multidict", specifier = "==6.7.1" }, - { name = "numpy", specifier = "==2.5.0" }, + { name = "numpy", specifier = "==2.5.1" }, { name = "packaging", specifier = "==26.2" }, { name = "pandas", specifier = "==2.3.2" }, { name = "pandas-stubs", specifier = "~=2.3.2" }, { name = "pg8000", specifier = "==1.31.5" }, - { name = "phonenumbers", specifier = "==9.0.33" }, - { name = "pillow", specifier = "==12.2.0" }, + { name = "phonenumbers", specifier = "==9.0.34" }, + { name = "pillow", specifier = "==12.3.0" }, { name = "pluggy", specifier = "==1.6.0" }, { name = "propcache", specifier = "==0.5.2" }, - { name = "proto-plus", specifier = "==1.28.0" }, + { name = "proto-plus", specifier = "==1.28.1" }, { name = "protobuf", specifier = "==6.33.5" }, { name = "psycopg2-binary", specifier = ">=2.9.12" }, - { name = "pyasn1", specifier = "==0.6.3" }, + { name = "pyasn1", specifier = "==0.6.4" }, { name = "pyasn1-modules", specifier = "==0.4.2" }, { name = "pycparser", specifier = "==3.0" }, { name = "pydantic", specifier = "==2.12.5" }, @@ -1674,25 +1693,25 @@ requires-dist = [ { name = "pytz", specifier = "==2026.2" }, { name = "requests", specifier = "==2.34.2" }, { name = "rsa", specifier = "==4.9.1" }, - { name = "scramp", specifier = "==1.4.10" }, - { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.63.0" }, + { name = "scramp", specifier = "==1.4.12" }, + { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.65.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-continuum", specifier = "==1.6.0" }, + { 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 = "starlette", specifier = "==1.3.1" }, - { name = "starlette-admin", extras = ["i18n"], specifier = "==0.16.1" }, + { name = "starlette-admin", extras = ["i18n"], specifier = "==0.17.0" }, { name = "typer", specifier = "==0.26.8" }, - { name = "typing-extensions", specifier = "==4.15.0" }, + { name = "typing-extensions", specifier = "==4.16.0" }, { name = "typing-inspection", specifier = "==0.4.2" }, { name = "tzdata", specifier = "==2025.3" }, { name = "urllib3", specifier = "==2.7.0" }, { name = "utm", specifier = "==0.8.1" }, - { name = "uvicorn", specifier = "==0.49.0" }, + { name = "uvicorn", specifier = "==0.51.0" }, { name = "yarl", specifier = "==1.24.2" }, ] @@ -1845,69 +1864,73 @@ wheels = [ [[package]] name = "phonenumbers" -version = "9.0.33" +version = "9.0.34" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/37/dfc4cf24169f1a7169ebaedaf896c818f0add8603409d1e748e3085ccdc0/phonenumbers-9.0.33.tar.gz", hash = "sha256:9ab8a02b940b90c64f3866c0b25a30e567ddf7bb9836a3e11efdb0478f65fc1c", size = 2306756, upload-time = "2026-06-22T10:23:33.428Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/29/f7e30e3dbd3c7e3d9c4a55006112c04ee62b4765a31f21bcc28c253ac3f1/phonenumbers-9.0.33-py2.py3-none-any.whl", hash = "sha256:ba1d0da52711d5fdda6b2b673b2621fe80774fc5d1b2e5a6ef783396b0343186", size = 2595422, upload-time = "2026-06-22T10:23:29.925Z" }, + { 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" }, ] [[package]] name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] [[package]] @@ -2023,14 +2046,14 @@ wheels = [ [[package]] name = "proto-plus" -version = "1.28.0" +version = "1.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" }, + { 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" }, ] [[package]] @@ -2108,11 +2131,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.3" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] @@ -2750,27 +2773,27 @@ wheels = [ [[package]] name = "scramp" -version = "1.4.10" +version = "1.4.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asn1crypto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/3c/fa5b7b95d29feea7de913c42ce6fe9ed9be21a13c4ee7307ba2a2a78755d/scramp-1.4.10.tar.gz", hash = "sha256:084a1d2784a2399ca5021209b490120458882e85e03105c338446ccfe19bee1e", size = 18123, upload-time = "2026-06-27T08:25:36.191Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/f3/fa74bbc0dcd15c624b352525cfd6d19bd7441799fda8573be76145f9a37b/scramp-1.4.10-py3-none-any.whl", hash = "sha256:e187fe49290718406cdaf2f1b56507965e8d9f5f458f478ef946a811eeea382e", size = 13943, upload-time = "2026-06-27T08:25:34.755Z" }, + { 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" }, ] [[package]] name = "sentry-sdk" -version = "2.63.0" +version = "2.65.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/c8/b3c970a5b186722d276cd40a05b3254e03bccc0208560aff20f612e018e8/sentry_sdk-2.63.0.tar.gz", hash = "sha256:2a1502bf864769275dbc8c2c9fc7a0f7f5e18358180b615d262d13a31ffba216", size = 912449, upload-time = "2026-06-16T12:45:57.553Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/1f/ed17a390348156ca99fe622b97cd7d2f1969b5f49df89084b0f28e7953e9/sentry_sdk-2.65.0.tar.gz", hash = "sha256:c94dc945d54bad49d4f20448b1e6b217ca2f92f46d05c3e83d41764af685c3d1", size = 932133, upload-time = "2026-07-13T11:33:19.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/57/cb205f7d93373120f666b9c5736dc0815524d96a9b278e7a728f018dc22a/sentry_sdk-2.63.0-py3-none-any.whl", hash = "sha256:3a9b5ddd403f79eb73bd670f75f04485819db53d28f76ced7bc09041cb0dfd6a", size = 495950, upload-time = "2026-06-16T12:45:55.819Z" }, + { url = "https://files.pythonhosted.org/packages/21/3b/326ad4c03b5da89b5124c8890af66e8119c4d2e10abc0619e0d67d9f7c7f/sentry_sdk-2.65.0-py3-none-any.whl", hash = "sha256:3595169677a808e4d0e1ea6ffb89443459549c7a98392ed71c77c847182ab6bf", size = 503869, upload-time = "2026-07-13T11:33:17.71Z" }, ] [package.optional-dependencies] @@ -2884,14 +2907,14 @@ wheels = [ [[package]] name = "sqlalchemy-continuum" -version = "1.6.0" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/95/0a5c5cb544804e0be6a32a63ba3204b54877f50999cca03179a8eaa82b31/sqlalchemy_continuum-1.6.0.tar.gz", hash = "sha256:4be2b66c5b951fdccf38da5b45c56f64f45b7656fe69f56310bf723548f612fc", size = 94037, upload-time = "2026-01-23T01:12:46.194Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/c8/10add9693dd2f2666ba3e8688e9163cb0f91a68704ac8d580e80a49c5135/sqlalchemy_continuum-1.7.0.tar.gz", hash = "sha256:30efaa26a0c6325ac70d034847e6e972ae70616b6553bb016a27f7ed2f1a1003", size = 95398, upload-time = "2026-07-03T02:25:53.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/6e/6818134ff199b9b08d92f79ddde6667e19ab835ef2d0732631935d6a7041/sqlalchemy_continuum-1.6.0-py3-none-any.whl", hash = "sha256:8768a402146f5a71b5b86dc4157c72b10ca86e2eecaf5e575c77c3d0811e6768", size = 54557, upload-time = "2026-01-23T01:12:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/bd/af/4f27754e52f8c23d5a0326b9b013f313243ed1bbf638be660dcd5daa4c41/sqlalchemy_continuum-1.7.0-py3-none-any.whl", hash = "sha256:6bdce869bfc0b6f22b51ea3552669b68acfa05791a6349b29457cd1bab84dd7a", size = 53975, upload-time = "2026-07-03T02:25:51.827Z" }, ] [[package]] @@ -2942,16 +2965,16 @@ wheels = [ [[package]] name = "starlette-admin" -version = "0.16.1" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "python-multipart" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/36/a566ba401ba9cbf5da74a7697e0ae3d1294a8ab7211bb1d7d18f0ce42b3f/starlette_admin-0.16.1.tar.gz", hash = "sha256:f34bdba033f0a2a4b39188e8e70a684956ef6b774db01762f44d022cde418b3a", size = 2104666, upload-time = "2026-06-06T20:10:40.307Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8b/fd9fb6165f280105fc2cba6582143f88e2ffd10546cda9b33b42be44819b/starlette_admin-0.17.0.tar.gz", hash = "sha256:ccc8229a8224d3da3b3cb4ef410c264eaedccc05d4d2f1ae5281374208110456", size = 2106612, upload-time = "2026-07-12T00:07:48.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/7c/add7d4be6690fb61f4d30b672e544b1ac541c824f05a7de50ff5d8985db5/starlette_admin-0.16.1-py3-none-any.whl", hash = "sha256:a5e6cb0beb2e9bdc65b07dbb4bd01726aaad34764595e3bc3ff8578e698ff98a", size = 2176514, upload-time = "2026-06-06T20:10:42.308Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ac/9d4141d12cef26c37143c7e4975f4d60383c1883adaa590b7dbdf163c33a/starlette_admin-0.17.0-py3-none-any.whl", hash = "sha256:fe3d29dfc4073ba6e5a3eb68110aa87a7ce18f735a9e8df8bd9663907b153f03", size = 2183316, upload-time = "2026-07-12T00:07:49.973Z" }, ] [package.optional-dependencies] @@ -2994,11 +3017,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -3054,15 +3077,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.49.0" +version = "0.51.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, + { 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" }, ] [[package]] From c04ff6f829b2c653d93ca06d80f41fb8550dac72 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:24:16 +0000 Subject: [PATCH 149/160] build(deps): bump cachetools from 5.5.2 to 7.1.4 Bumps [cachetools](https://github.com/tkem/cachetools) from 5.5.2 to 7.1.4. - [Changelog](https://github.com/tkem/cachetools/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tkem/cachetools/compare/v5.5.2...v7.1.4) --- updated-dependencies: - dependency-name: cachetools dependency-version: 7.1.4 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 0f1d8eda1..1f3445c32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "attrs==26.1.0", "authlib==1.7.2", "bcrypt==4.3.0", - "cachetools==5.5.2", + "cachetools==7.1.4", "certifi==2026.6.17", "cffi==2.1.0", "charset-normalizer==3.4.9", diff --git a/requirements.txt b/requirements.txt index 610eb6525..90c7ff4b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -231,9 +231,9 @@ blinker==1.9.0 \ --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc # via flask -cachetools==5.5.2 \ - --hash=sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4 \ - --hash=sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a +cachetools==7.1.4 \ + --hash=sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54 \ + --hash=sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6 # via ocotilloapi certifi==2026.6.17 \ --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ diff --git a/uv.lock b/uv.lock index af5d19844..453d7b040 100644 --- a/uv.lock +++ b/uv.lock @@ -391,11 +391,11 @@ wheels = [ [[package]] name = "cachetools" -version = "5.5.2" +version = "7.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, + { 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" }, ] [[package]] @@ -1631,7 +1631,7 @@ requires-dist = [ { name = "attrs", specifier = "==26.1.0" }, { name = "authlib", specifier = "==1.7.2" }, { name = "bcrypt", specifier = "==4.3.0" }, - { name = "cachetools", specifier = "==5.5.2" }, + { name = "cachetools", specifier = "==7.1.4" }, { name = "certifi", specifier = "==2026.6.17" }, { name = "cffi", specifier = "==2.1.0" }, { name = "charset-normalizer", specifier = "==3.4.9" }, From e71d88c014229b01dc41f58f51f50da669b71d65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:06:06 +0000 Subject: [PATCH 150/160] build(deps): bump actions/setup-python from 6.3.0 to 7.0.0 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.3.0 to 7.0.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v6.3.0...v7) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/format_code.yml | 2 +- .github/workflows/jira_codex_pr.yml | 2 +- .github/workflows/tests.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/format_code.yml b/.github/workflows/format_code.yml index a9c98da93..6eb001bed 100644 --- a/.github/workflows/format_code.yml +++ b/.github/workflows/format_code.yml @@ -19,7 +19,7 @@ jobs: - name: Check out source repository uses: actions/checkout@v7.0.0 - name: Set up Python environment - 3.12 - uses: actions/setup-python@v6.3.0 + uses: actions/setup-python@v7.0.0 with: python-version: "3.12" cache: "pip" diff --git a/.github/workflows/jira_codex_pr.yml b/.github/workflows/jira_codex_pr.yml index 9f559234d..77abedc4a 100644 --- a/.github/workflows/jira_codex_pr.yml +++ b/.github/workflows/jira_codex_pr.yml @@ -54,7 +54,7 @@ jobs: fi - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ env.PYTHON_VERSION }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a9a357548..dad6ef5e9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -70,7 +70,7 @@ jobs: - name: Set up Python id: setup-python - uses: actions/setup-python@v6.3.0 + uses: actions/setup-python@v7.0.0 with: python-version-file: "pyproject.toml" @@ -162,7 +162,7 @@ jobs: - name: Set up Python id: setup-python - uses: actions/setup-python@v6.3.0 + uses: actions/setup-python@v7.0.0 with: python-version-file: "pyproject.toml" From fa0b3e3b27beb8d0b6037d929e965bee3e95e36e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:21:00 +0000 Subject: [PATCH 151/160] build(deps): bump the uv-non-major group with 18 updates (#785) 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 | | --- | --- | --- | | [asgiref](https://github.com/django/asgiref) | `3.11.1` | `3.12.1` | | [fastapi](https://github.com/fastapi/fastapi) | `0.139.0` | `0.139.2` | | [google-api-core](https://github.com/googleapis/google-cloud-python) | `2.31.0` | `2.32.0` | | [google-auth](https://github.com/googleapis/google-cloud-python) | `2.55.2` | `2.56.0` | | [google-cloud-storage](https://github.com/googleapis/google-cloud-python) | `3.12.1` | `3.13.0` | | [pygeoapi](https://github.com/geopython/pygeoapi) | `0.23.4` | `0.23.5` | | [sentry-sdk[fastapi]](https://github.com/getsentry/sentry-python) | `2.65.0` | `2.66.0` | | [starlette-admin[i18n]](https://github.com/jowilf/starlette-admin) | `0.17.0` | `0.17.1` | | [typer](https://github.com/fastapi/typer) | `0.26.8` | `0.27.0` | | [yarl](https://github.com/aio-libs/yarl) | `1.24.2` | `1.24.5` | | [filelock](https://github.com/tox-dev/py-filelock) | `3.29.7` | `3.31.1` | | [joserfc](https://github.com/authlib/joserfc) | `1.7.3` | `1.7.4` | | [opentelemetry-api](https://github.com/open-telemetry/opentelemetry-python) | `1.43.0` | `1.44.0` | | [opentelemetry-sdk](https://github.com/open-telemetry/opentelemetry-python) | `1.43.0` | `1.44.0` | | [opentelemetry-semantic-conventions](https://github.com/open-telemetry/opentelemetry-python) | `0.64b0` | `0.65b0` | | [regex](https://github.com/mrabarnett/mrab-regex) | `2026.7.10` | `2026.7.19` | | [sentry-sdk](https://github.com/getsentry/sentry-python) | `2.65.0` | `2.66.0` | | [starlette-admin](https://github.com/jowilf/starlette-admin) | `0.17.0` | `0.17.1` | Updates `asgiref` from 3.11.1 to 3.12.1
    Changelog

    Sourced from asgiref's changelog.

    3.12.1 (2026-07-14)

    • Restored the previous SyncToAsync.call internal code shape, which was relied on by some APM services. (#572)

      Note, this change was available whilst maintaining the underlying fix (from #564). It does not constitute an API stability promise. Ideally APMs are not monkey patching internal APIs, and future changes will be made here if needed.

    3.12.0 (2026-07-14)

    • AsyncToSync no longer captures the running event loop on instantiation. (#562)

      This resolves a series of deadlocks that users experienced after asgiref 3.9.0, particularly with pytest-asyncio. pytest-asyncio stops the event loop between tests, and long-running unawaited futures could find themselves trying to schedule work onto a stopped loop, and so would never complete. Ideally, code should be structured to await long-running futures before returning, but this change should help users experiencing issues here.

      The loop is now resolved when the callable is invoked rather than when it is created. If async_to_sync is called from within sync_to_async, the parent event loop is still used, as before.

      The possibility of deadlock therefore remains in some nested patterns. For example, an async function may call a long-running sync_to_async function that itself uses async_to_sync; if the outer function returns before the sync future completes, the parent event loop may already be stopped, and the nested calls cannot be driven to completion.

      This is not a bug in asgiref — the same patterns deadlock in plain asyncio. As above, restructure your code to await the sync_to_async future before exiting the driving coroutine.

    • Fixed an event loop deadlock when exiting ThreadSensitiveContext while its executor thread was still blocked waiting on the event loop. (#535)

    • Dropped support for EOL Python 3.9.

    • Fixed StatelessServer.run() failing on Python 3.14, where asyncio.get_event_loop() no longer creates an event loop if none exists. It now uses asyncio.run(). (#559)

    • Fixed Local leaking data between unrelated sync threads when sys.flags.thread_inherit_context is enabled (Python 3.14+), so a newly

    ... (truncated)

    Commits
    • ef9d4b8 Releasing 3.12.1
    • 34fba63 Restore previous SyncToAsync.call internal code shape.
    • a43900c Separate mypy from tests extra.
    • 1b7c338 Releasing 3.12.0
    • 157d9d4 Renovate precommit (#552)
    • deda0d4 Test free-threading builds and fix Local data leak for thread_inherit_context...
    • a54250a Don’t capture the event loop in AsyncToSync.__init__ (#562)
    • 836356a Use asyncio.run in StatelessServer.run (#561)
    • e04afd5 Dropped support for Python 3.9. (#543)
    • 95d2430 Fixed #535: ThreadSensitiveContext.aexit blocking the event loop. (#563)
    • Additional commits viewable in compare view

    Updates `fastapi` from 0.139.0 to 0.139.2
    Release notes

    Sourced from fastapi's releases.

    0.139.2

    Fixes

    • 🐛 Refactor router route building to make it thread-safe, mainly relevant for tests running in parallel threads (uncommon). PR #16013 by @​tiangolo.

    0.139.1

    Fixes

    • 🐛 Fix frontend fallback support for doted paths like /users/john.doe. PR #16011 by @​tiangolo.

    Docs

    • 📝 Fix topic repository list not being displayed and skip_users not being applied. PR #15995 by @​YuriiMotov.

    Translations

    Internal

    Commits
    • 866b7a3 🔖 Release version 0.139.2 (#16014)
    • 7b3effe 📝 Update release notes
    • 7fe315c 🐛 Refactor router route building to make it thread-safe, mainly relevant for ...
    • c48e67b 🔖 Release version 0.139.1 (#16012)
    • 2acc4fb 📝 Update release notes
    • eb75fd0 🐛 Fix frontend fallback support for doted paths like /users/john.doe (#16011)
    • 9b8410b 📝 Update release notes
    • e24d44c 📝 Fix topic repository list not being displayed and skip_users not being ap...
    • 93b78f8 📝 Update release notes
    • b959b44 📝 Update release notes
    • Additional commits viewable in compare view

    Updates `google-api-core` from 2.31.0 to 2.32.0
    Release notes

    Sourced from google-api-core's releases.

    google-api-core: v2.32.0

    2.32.0 (2026-07-16)

    Features

    • implement PEP 0810 lazy loading in operations_v1 (#17724) (22c5304)

    Bug Fixes

    • api_core: clarify misleading http 404 unimplemented error message (#17681) (00b9040)
    Commits
    • 97d7b42 chore: release main (#17701)
    • 7823422 chore(main): release bigframes 2.46.0 (#17686)
    • ce5fd50 fix: emit bracketed inline array syntax for scalar subquery expressions (#17716)
    • 2c3c213 feat(bigtable): support materialized views in the data client (#17676)
    • 1ef0340 chore(deps): update dependency cryptography to v48.0.1 [security] (#17734)
    • 75d30d5 chore(deps): update dependency esbuild to ^0.28.0 [security] (#17736)
    • 9019b2a test(spanner): update pytest and add pytest-asyncio to samples test requireme...
    • a2b4096 test(spanner): add pytest-asyncio to samples test requirements (#17731)
    • 5d3ca44 test: catch expected UserWarning in test_repr_mimebundle_selection_logic (#17...
    • 35e16da feat: update googleapis and regenerate (#17725)
    • Additional commits viewable in compare view

    Updates `google-auth` from 2.55.2 to 2.56.0
    Release notes

    Sourced from google-auth's releases.

    google-auth: v2.56.0

    2.56.0 (2026-07-13)

    Features

    Bug Fixes

    Commits
    • 7c18c24 chore: release main (#17646)
    • 8feb1b8 tests(bigquery): implement robust wait loop for socket leak tests (#17688)
    • e5f7fef fix: bump mistune from 3.2.1 to 3.3.0 in /packages/bigframes (#17694)
    • bd5d1a4 docs: add project ID to pandas-gbq run sample (#17692)
    • 635da34 fix: bump soupsieve from 2.7 to 2.8.4 in /packages/bigframes (#17695)
    • 4253fab feat(bigframes): support offset-based column access via iloc (#17367)
    • a5a717d feat(storage): add option to disable checksums and improve robustness of full...
    • fc423c8 docs: make landing page quickstart runnable (#17687)
    • cae94f9 feat(bigframes): Support groupby.agg/transform with udf transpiler (#17613)
    • 91f93bc fix(bigframes): Fix sqlglot backend regressions (#17655)
    • Additional commits viewable in compare view

    Updates `google-cloud-storage` from 3.12.1 to 3.13.0
    Release notes

    Sourced from google-cloud-storage's releases.

    google-cloud-storage: v3.13.0

    3.13.0 (2026-07-13)

    Features

    • storage: add option to disable checksums and improve robustness of full_object_checksum validation (#17665) (a5a717d)
    • storage: support full_object_checksum in AsyncAppendableObjectWriter (#17658) (e08d5ca)
    Changelog

    Sourced from google-cloud-storage's changelog.

    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)

    Documentation

    Features

    Bug Fixes

    • Removed the SpannerIndexingConfig message and the spanner_indexing_config field from .google.cloud.documentai.v1beta3.Dataset BREAKING CHANGE: The SpannerIndexingConfig message and the spanner_indexing_config field within the Dataset message have been removed. Client code referencing these will need to stop referencing these in case of an error (5371e8e931dfba1d504ac2ffbd48a7f4abdcc158)

    ... (truncated)

    Commits
    • 7c18c24 chore: release main (#17646)
    • 8feb1b8 tests(bigquery): implement robust wait loop for socket leak tests (#17688)
    • e5f7fef fix: bump mistune from 3.2.1 to 3.3.0 in /packages/bigframes (#17694)
    • bd5d1a4 docs: add project ID to pandas-gbq run sample (#17692)
    • 635da34 fix: bump soupsieve from 2.7 to 2.8.4 in /packages/bigframes (#17695)
    • 4253fab feat(bigframes): support offset-based column access via iloc (#17367)
    • a5a717d feat(storage): add option to disable checksums and improve robustness of full...
    • fc423c8 docs: make landing page quickstart runnable (#17687)
    • cae94f9 feat(bigframes): Support groupby.agg/transform with udf transpiler (#17613)
    • 91f93bc fix(bigframes): Fix sqlglot backend regressions (#17655)
    • Additional commits viewable in compare view

    Updates `pygeoapi` from 0.23.4 to 0.23.5
    Release notes

    Sourced from pygeoapi's releases.

    0.23.5

    This is a security release which addresses the following security advisory:

    Users deploying pygeoapi with resources using the OGR Provider using SQLite/GeoPackage or relational database data sources are strongly advised to update to pygeoapi 0.23.5.

    The pygeoapi team gives thanks for all contributions made for this release.

    As always, all contributions are always welcome.

    The pygeoapi team https://pygeoapi.io/

    Commits

    Updates `sentry-sdk[fastapi]` from 2.65.0 to 2.66.0
    Release notes

    Sourced from sentry-sdk[fastapi]'s releases.

    2.66.0

    New Features ✨

    • (tracing) Promote trace_lifecycle and ignore_spans to top-level options by @​ericapisani in #6821

    Bug Fixes 🐛

    Tracing

    • Skip child span creation in streaming path when no current span (HTTP clients) by @​sentrivana in #6811
    • Skip child span creation in streaming path when no current span (task queues) by @​sentrivana in #6814
    • Skip child span creation in streaming path when no current span (misc) by @​sentrivana in #6815
    • Skip child span creation in streaming path when no current span (databases) by @​sentrivana in #6808
    • Skip child span creation in streaming path when no current span (web frameworks) by @​sentrivana in #6810
    • Skip child span creation in streaming path when no current span (django) by @​sentrivana in #6809

    Internal Changes 🔧

    Changelog

    Sourced from sentry-sdk[fastapi]'s changelog.

    2.66.0

    New Features ✨

    • (tracing) Promote trace_lifecycle and ignore_spans to top-level options by @​ericapisani in #6821

    Bug Fixes 🐛

    Tracing

    • Skip child span creation in streaming path when no current span (HTTP clients) by @​sentrivana in #6811
    • Skip child span creation in streaming path when no current span (task queues) by @​sentrivana in #6814
    • Skip child span creation in streaming path when no current span (misc) by @​sentrivana in #6815
    • Skip child span creation in streaming path when no current span (databases) by @​sentrivana in #6808
    • Skip child span creation in streaming path when no current span (web frameworks) by @​sentrivana in #6810
    • Skip child span creation in streaming path when no current span (django) by @​sentrivana in #6809

    Internal Changes 🔧

    Commits
    • 5179f60 update changelog with docs link
    • e4bcb59 release: 2.66.0
    • 5ba869c fix(tracing): Skip child span creation in streaming path when no current span...
    • efddaab fix(tracing): Skip child span creation in streaming path when no current span...
    • b199a9e fix(tracing): Skip child span creation in streaming path when no current span...
    • d695a6e fix(tracing): Skip child span creation in streaming path when no current span...
    • dbf5d03 fix(tracing): Skip child span creation in streaming path when no current span...
    • 5c14034 fix(tracing): Skip child span creation in streaming path when no current span...
    • e0d2c4a feat(tracing): Promote trace_lifecycle and ignore_spans to top-level options ...
    • baa423f test(logging): Fix flaky test_logging_captured_warnings (#6824)
    • Additional commits viewable in compare view

    Updates `starlette-admin[i18n]` from 0.17.0 to 0.17.1
    Release notes

    Sourced from starlette-admin[i18n]'s releases.

    0.17.1

    What's Changed

    New Contributors

    Full Changelog: https://github.com/jowilf/starlette-admin/compare/0.17.0...0.17.1

    Commits

    Updates `typer` from 0.26.8 to 0.27.0
    Release notes

    Sourced from typer's releases.

    0.27.0

    Breaking Changes

    Internal

    Changelog

    Sourced from typer's changelog.

    0.27.0 (2026-07-15)

    Breaking Changes

    Internal

    Commits

    Updates `yarl` from 1.24.2 to 1.24.5
    Changelog

    Sourced from yarl's changelog.

    v1.24.5

    (2026-07-19)

    Contributor-facing changes

    • Restricted the exhaustive IDNA default-ignorable sweep test to a native Linux x86_64 runner. It iterates roughly 140,000 code points and its result does not depend on the architecture, so running it under emulated wheel builds only added minutes and intermittently crashed the test workers -- by :user:bdraco.

      Related issues and pull requests on GitHub: :issue:1806.


    v1.24.4

    (2026-07-19)

    Packaging updates and notes for downstreams

    • Stopped installing hypothesis in the wheel-build test environment. The property-based quoting tests that need it are skipped there already, and building it from source on architectures without a prebuilt wheel (such as armv7l musllinux, where the build pulls in a Rust toolchain) was failing the wheel jobs -- by :user:bdraco.

      Related issues and pull requests on GitHub: :issue:1804.


    v1.24.3

    (2026-07-19)

    ... (truncated)

    Commits
    • 0b30cb0 Release 1.24.5 (#1807)
    • 56150c8 Run the IDNA sweep test only on native Linux x86_64 (#1806)
    • 515adca Release 1.24.4 (#1805)
    • ab5dec2 Do not install hypothesis in the wheel-build test env (#1804)
    • f9d10fb Release 1.24.3 (#1803)
    • 058b7cb Test lone surrogate handling in encoded and parsed URL paths (#991)
    • a181cee Update hypothesis requirement from >=6.156.4 to >=6.157.0 in /requirements (#...
    • 28c1bcc Encode scheme-shaped path colon in relative-path builders (#1802)
    • 51e2802 Reject hosts with Unicode default-ignorable code points (#1801)
    • 3921a73 Drop lone surrogates that split a percent escape in the Cython quoter (#1752)
    • Additional commits viewable in compare view

    Updates `filelock` from 3.29.7 to 3.31.1
    Release notes

    Sourced from filelock's releases.

    3.31.1

    What's Changed

    Full Changelog: https://github.com/tox-dev/filelock/compare/3.31.0...3.31.1

    3.31.0

    What's Changed

    Full Changelog: https://github.com/tox-dev/filelock/compare/3.30.3...3.31.0

    3.30.3

    What's Changed

    New Contributors

    Full Changelog: https://github.com/tox-dev/filelock/compare/3.30.2...3.30.3

    3.30.2

    What's Changed

    Full Changelog: https://github.com/tox-dev/filelock/compare/3.30.1...3.30.2

    3.30.1

    What's Changed

    ... (truncated)

    Changelog

    Sourced from filelock's changelog.

    ########### Changelog ###########

    .. towncrier-draft-entries:: Unreleased

    .. towncrier release notes start


    3.31.1 (2026-07-20)


    • A SoftFileLease acquired on one thread keeps its claim when another thread fails to acquire the same lease object, so its heartbeat carries on refreshing the marker instead of being torn down and letting a peer take the live claim. :pr:680

    3.31.0 (2026-07-18)


    • Support Termux/Android, whose CPython ships without os.link and reports sys.platform == "android". import filelock and both FileLock and SoftFileLock now work there, StrictSoftFileLock re... _Description has been truncated_ Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 20 +- requirements.txt | 473 ++++++++++++++++++++++++++--------------------- uv.lock | 188 ++++++++++--------- 3 files changed, 368 insertions(+), 313 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0f1d8eda1..c7fc3a5d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "annotated-types==0.7.0", "anyio==4.14.2", "apitally[fastapi]==0.25.1", - "asgiref==3.11.1", + "asgiref==3.12.1", "asn1crypto==1.5.1", "asyncpg==0.31.0", "attrs==26.1.0", @@ -30,14 +30,14 @@ dependencies = [ "dnspython==2.8.0", "dotenv==0.9.9", "email-validator==2.3.0", - "fastapi==0.139.0", + "fastapi==0.139.2", "fastapi-pagination==0.15.15", "frozenlist==1.8.0", "geoalchemy2==0.20.0", - "google-api-core==2.31.0", - "google-auth==2.55.2", + "google-api-core==2.32.0", + "google-auth==2.56.0", "google-cloud-core==2.6.0", - "google-cloud-storage==3.12.1", + "google-cloud-storage==3.13.0", "google-crc32c==1.8.0", "google-resumable-media==2.10.0", "googleapis-common-protos==1.75.0", @@ -72,7 +72,7 @@ dependencies = [ "pydantic-core==2.41.5", "pygments==2.20.0", "pyjwt==2.13.0", - "pygeoapi==0.23.4", + "pygeoapi==0.23.5", "pyproj==3.7.2", "pyshp==2.3.1", "python-dateutil==2.9.0.post0", @@ -82,7 +82,7 @@ dependencies = [ "requests==2.34.2", "rsa==4.9.1", "scramp==1.4.12", - "sentry-sdk[fastapi]==2.65.0", + "sentry-sdk[fastapi]==2.66.0", "shapely==2.1.2", "six==1.17.0", "sniffio==1.3.1", @@ -92,15 +92,15 @@ dependencies = [ "sqlalchemy-utils==0.42.1", "sqlparse>=0.5.5", "starlette==1.3.1", - "starlette-admin[i18n]==0.17.0", - "typer==0.26.8", + "starlette-admin[i18n]==0.17.1", + "typer==0.27.0", "typing-extensions==4.16.0", "typing-inspection==0.4.2", "tzdata==2025.3", "urllib3==2.7.0", "utm==0.8.1", "uvicorn==0.51.0", - "yarl==1.24.2", + "yarl==1.24.5", "pymssql>=2.3.13", ] diff --git a/requirements.txt b/requirements.txt index 610eb6525..7d019c1ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -122,9 +122,9 @@ apitally==0.25.1 \ --hash=sha256:2681e925deffbc94eb7fc65e1f0db397df58634ab1d90597be458d69b2185f7b \ --hash=sha256:8281fa67fb5cae8cd5d84146cd5e2e0851be7b8c5fe27a6605cdaa5065d00483 # via ocotilloapi -asgiref==3.11.1 \ - --hash=sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce \ - --hash=sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133 +asgiref==3.12.1 \ + --hash=sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340 \ + --hash=sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094 # via ocotilloapi asn1crypto==1.5.1 \ --hash=sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c \ @@ -544,9 +544,9 @@ email-validator==2.3.0 \ --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 # via ocotilloapi -fastapi==0.139.0 \ - --hash=sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145 \ - --hash=sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189 +fastapi==0.139.2 \ + --hash=sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e \ + --hash=sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c # via # apitally # fastapi-pagination @@ -556,9 +556,9 @@ fastapi-pagination==0.15.15 \ --hash=sha256:d6e9e4bc4d6e20709dcabc11b16056cd5cd184c995ee214b0190f6b81426fa0c \ --hash=sha256:dc828d7cd15614c650c284bd2c3a98a8a2d9ce340508be3970dc8986908a02aa # via ocotilloapi -filelock==3.29.7 \ - --hash=sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d \ - --hash=sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51 +filelock==3.31.1 \ + --hash=sha256:9e0c4e88ebe90833c1beafd3a547ccbc0bf7f491cd3858c3ec7aed63efe02163 \ + --hash=sha256:9ea33146c780161bf67cb20c7cb26b651566820d65ad8dfdd79422602a2dcfc0 # via pygeoapi flask==3.1.3 \ --hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \ @@ -639,16 +639,16 @@ geoalchemy2==0.20.0 \ --hash=sha256:1489a1d106519542a79c97cd0b4c537d80462c353610ebc2429cf2c43daac717 \ --hash=sha256:450f427f4bc3cf2d5ddee0af3763aed0f3eea2384e7c9a99798d8f1508279322 # via ocotilloapi -google-api-core==2.31.0 \ - --hash=sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2 \ - --hash=sha256:ef79fb3784c71cbac89cbd03301ba0c8fb8ad2aa95d7f9204dd9628f7adf59ab +google-api-core==2.32.0 \ + --hash=sha256:2b33aad226b19272458c46abfe5c5a38d9531ece0c44502129a1463ce83674ac \ + --hash=sha256:ae1f0d58a6c8869350bf469f8eb3092e7f8c494a942d9525494afb6c162b0904 # via # google-cloud-core # google-cloud-storage # ocotilloapi -google-auth==2.55.2 \ - --hash=sha256:97ae7790ff740f2bc9db60eb864a7804f4ac19f5f02c38b3d942f2fea6e9b9ae \ - --hash=sha256:d715f265f2cafc6a5f1bf0dc19870d20e3119f6f6682785a250bce3d03d38a3b +google-auth==2.56.0 \ + --hash=sha256:6e88c10217e07a92bfd01cac8ee99e32ccfb08414c3102e6c5b8d58f37a0d1e0 \ + --hash=sha256:f90fa030b569a92654b9d690665a073841df33d57487be53db583a9a0867a553 # via # cloud-sql-python-connector # google-api-core @@ -661,9 +661,9 @@ google-cloud-core==2.6.0 \ # via # google-cloud-storage # ocotilloapi -google-cloud-storage==3.12.1 \ - --hash=sha256:1d81491c7663bc26c5056d00b834356f2253b910ef467f9cf9928a87fca1e04b \ - --hash=sha256:9297ae0c2ce3f5400b1f2bb3a3e6d2cd256614366e03cd30600871df8e903afb +google-cloud-storage==3.13.0 \ + --hash=sha256:648af3ef8a6acc674e1359d3c920c67eb89a7a5ab66b336bd3ac43fed6b5ab84 \ + --hash=sha256:d11d8706ea1520fba0f21043bcb7897caf7015d76ce1ad9a4f60237e4d7a9f6c # via ocotilloapi google-crc32c==1.8.0 \ --hash=sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa \ @@ -831,9 +831,9 @@ jinja2==3.1.6 \ # ocotilloapi # pygeoapi # starlette-admin -joserfc==1.7.3 \ - --hash=sha256:116955c2587139dba20621fd0bd7fc9255fa960c9fe7f43c43ebef2e801dcfcf \ - --hash=sha256:7c39f3f2c943dbc03122747fa8ebbd8e156e54904cf25651b452f4d2634a6075 +joserfc==1.7.4 \ + --hash=sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463 \ + --hash=sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed # via authlib jsonschema==4.26.0 \ --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ @@ -1043,19 +1043,19 @@ numpy==2.5.1 \ # pandas-stubs # rasterio # shapely -opentelemetry-api==1.43.0 \ - --hash=sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1 \ - --hash=sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd +opentelemetry-api==1.44.0 \ + --hash=sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a \ + --hash=sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef # via # opentelemetry-sdk # opentelemetry-semantic-conventions -opentelemetry-sdk==1.43.0 \ - --hash=sha256:d1323a547c1ce69d6a069a17a44b7da82bb8b332051ecb074041f87642c86823 \ - --hash=sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9 +opentelemetry-sdk==1.44.0 \ + --hash=sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b \ + --hash=sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad # via apitally -opentelemetry-semantic-conventions==0.64b0 \ - --hash=sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c \ - --hash=sha256:ea77e85e354b8f604ddbe5f3d9135216f982fa4d77e5859ac30f6d8a50505aa6 +opentelemetry-semantic-conventions==0.65b0 \ + --hash=sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb \ + --hash=sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60 # via opentelemetry-sdk packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ @@ -1403,9 +1403,9 @@ pydantic-core==2.41.5 \ # via # ocotilloapi # pydantic -pygeoapi==0.23.4 \ - --hash=sha256:7f0fd854575a0da049b64907b56fc0f77ab97768414c1397897e60a0e563438d \ - --hash=sha256:935a22761eb0d8736f7b0f2c8384672f5341577509803e35f33f6e78299221ae +pygeoapi==0.23.5 \ + --hash=sha256:66ec6c466f00a2ec4af77b886bc4b046d70a2fde4cdf21021e551231db955e19 \ + --hash=sha256:9f1456738a8851c582f7336159dfaaa96ca9704b9c554c0cc59d699042314359 # via ocotilloapi pygeofilter==0.4.0 \ --hash=sha256:cbb4a5f14af0b87e4f0c0c81c659ff64e44351c98e9f61d36af515d896fa8a05 \ @@ -1632,121 +1632,121 @@ referencing==0.37.0 \ # via # jsonschema # jsonschema-specifications -regex==2026.7.10 \ - --hash=sha256:0639b2488b775a0109f55a5a2172deebdedb4b6c5ab0d48c90b43cbf5de58d17 \ - --hash=sha256:081acf191b4d614d573a56cab69f948b6864daa5e3cc69f209ee92e26e454c2f \ - --hash=sha256:0911e34151a5429d0325dae538ba9851ec0b62426bdfd613060cda8f1c36ec7f \ - --hash=sha256:103e8f3acc3dcede88c0331c8612766bdcfc47c9250c5477f0e10e0550b9da49 \ - --hash=sha256:1050fedf0a8a92e843971120c2f57c3a99bea86c0dfa1d63a9fac053fe54b135 \ - --hash=sha256:13fba679fe035037e9d5286620f88bbfd105df4d5fcd975942edd282ab986775 \ - --hash=sha256:14d27f6bd04beb01f6a25a1153d73e58c290fd45d92ba56af1bb44199fd1010d \ - --hash=sha256:177f930af3ad72e1045f8877540e0c43a38f7d328cf05f31963d0bd5f7ecf067 \ - --hash=sha256:1f0d4ccf70b1d13711242de0ba78967db5c35d12ac408378c70e06295c3f6644 \ - --hash=sha256:21150500b970b12202879dfd82e7fd809d8e853140fff84d08e57a90cf1e154e \ - --hash=sha256:2129e4a5e86f26926982d883dff815056f2e98220fdf630e59f961b578a26c43 \ - --hash=sha256:221f2771cb780186b94bbf125a151bbeb242fa1a971da6ad59d7b0370f19de9a \ - --hash=sha256:234f8e0d65cf1df9becadae98648f74030ee85a8f12edcb5eb0f60a22a602197 \ - --hash=sha256:28a0973eeffff4292f5a7ee498ab65d5e94ee8cc9cea364239251eb4a260a0f1 \ - --hash=sha256:2b93eafd92c4128bab2f93500e8912cc9ecb3d3765f6685b902c6820d0909b6b \ - --hash=sha256:2bc350e1c5fa250f30ab0c3e38e5cfdffcd82cb8af224df69955cab4e3003812 \ - --hash=sha256:2c66a8a1969cfd506d1e203c0005fd0fc3fe6efc83c945606566b6f9611d4851 \ - --hash=sha256:2f98ef73a13791a387d5c841416ad7f52040ae5caf10bcf46fa12bd2b3d63745 \ - --hash=sha256:31fa17378b29519bfd0a1b8ba4e9c10cf0baf1cf4099b39b0689429e7dc2c795 \ - --hash=sha256:3750c42d47712e362158a04d0fd80131f73a55e8c715b2885442a0ff6f9fc3fc \ - --hash=sha256:38a5926601aaccf379512746b86eb0ac1d29121f6c776dac6ac5b31077432f2c \ - --hash=sha256:396ea70e4ea1f19571940add3bad9fd3eb6a19dc610d0d01f692bc1ba0c10cb4 \ - --hash=sha256:39f81d1fdf594446495f2f4edd8e62d8eda0f7a802c77ac596dc8448ad4cc5ca \ - --hash=sha256:3d8ef9df02c8083c7b4b855e3cb87c8e0ebbcfea088d98c7a886aaefdf88d837 \ - --hash=sha256:3e23458d8903e33e7d27196d7a311523dc4e2f4137a5f34e4dbd30c8d37ff33e \ - --hash=sha256:3f03b92fb6ec739df042e45b06423fc717ecf0063e07ffe2897f7b2d5735e1e8 \ - --hash=sha256:3f361215e000d68a4aff375106637b83c80be36091d83ee5107ad3b32bd73f48 \ - --hash=sha256:41a47c2b28d9421e2509a4583a22510dc31d83212fcf38e1508a7013140f71a8 \ - --hash=sha256:441edc66a54063f8269d1494fc8474d06605e71e8a918f4bcfd079ebda4ce042 \ - --hash=sha256:4533af6099543db32ef26abc2b2f824781d4eebb309ab9296150fd1a0c7eb07d \ - --hash=sha256:4574feca202f8c470bf678aed8b5d89df04aaf8dc677f3b83d92825051301c0f \ - --hash=sha256:460176b2db044a292baaee6891106566739657877af89a251cded228689015a6 \ - --hash=sha256:494b19a5805438aeb582de99f9d97603d8fd48e6f4cc74d0088bb292b4da3b70 \ - --hash=sha256:4db009b4fc533d79af3e841d6c8538730423f82ea8508e353a3713725de7901c \ - --hash=sha256:538ddb143f5ca085e372def17ef3ed9d74b50ad7fc431bd85dc50a9af1a7076f \ - --hash=sha256:53bbbd6c610489700f7110db1d85f3623924c3f7c760f987eca033867360788a \ - --hash=sha256:53f54993b462f3f91fea0f2076b46deb6619a5f45d70dbd1f543f789d8b900ef \ - --hash=sha256:58a4571b2a093f6f6ee4fd281faa8ebf645abcf575f758173ea2605c7a1e1ecb \ - --hash=sha256:5c363de7c0339d39341b6181839ed32509820b85ef506deafcf2e7e43baadab4 \ - --hash=sha256:5e792367e5f9b4ffb8cad93f1beaa91837056b94da98aa5c65a0db0c1b474927 \ - --hash=sha256:5eab9d3f981c423afd1a61db055cfe83553c3f6455949e334db04722469dd0a2 \ - --hash=sha256:617e8f10472e34a8477931f978ff3a88d46ae2ba0e41927e580b933361f60948 \ - --hash=sha256:64722a5031aeace7f6c8d5ea9a9b22d9368af0d6e8fa532585da8158549ea963 \ - --hash=sha256:65ee5d1ac3cd541325f5ac92625b1c1505f4d171520dd931bda7952895c5321a \ - --hash=sha256:668ab85105361d0200e3545bec198a1acfc6b0aeb5fff8897647a826e5a171be \ - --hash=sha256:66d2c35587cd601c95965d5c0415058ba5cfd6ffbab7624ce198bd967102b341 \ - --hash=sha256:6cbedeb5112f59dbd169385459b9943310bdd241c6966c19c5f6e2295055c93a \ - --hash=sha256:6e3448e86b05ce87d4eb50f9c680860830f3b32493660b39f43957d6263e2eba \ - --hash=sha256:724ee9379568658ec06362cf24325c5315cc5a67f61dfe585bfeff58300a355b \ - --hash=sha256:7252b48b0c60100095088fbeb281fca9a4fcf678a4e04b1c520c3f8613c952c4 \ - --hash=sha256:732c19e5828eb287d01edb83b2eb87f283ba8e5fc3441c732709d3e8cbd14aaa \ - --hash=sha256:749b92640e1970e881fdf22a411d74bf9d049b154f4ef7232eeb9a90dd8be7f3 \ - --hash=sha256:74ae61d8573ecd51b5eeee7be2218e4c56e99c14fa8fcf97cf7519611d4be92e \ - --hash=sha256:78712d4954234df5ca24fdadb65a2ab034213f0cdfde376c272f9fc5e09866bb \ - --hash=sha256:799a369bdab91dcf0eb424ebd7aa9650897025ce22f729248d8f2c72002c4daa \ - --hash=sha256:80151ca5bfc6c4524186b3e08b499e97319b2001fc265ed2d4fc12c0d5692cdf \ - --hash=sha256:82ab8330e7e2e416c2d42fcec67f02c242393b8681014750d4b70b3f158e1f08 \ - --hash=sha256:8331484450b3894298bef8abecce532171ff6ac60b71f999eed10f2c01941a8a \ - --hash=sha256:834271b1ff2cfa1f67fcd65a48bf11d11e9ab837e21bf79ce554efb648599ae8 \ - --hash=sha256:8679f0652a183d93da646fcec8da8228db0be40d1595da37e6d74c2dc8c4713c \ - --hash=sha256:87794549a3f5c1c2bdfba2380c1bf87b931e375f4133d929da44f95e396bf5fe \ - --hash=sha256:87b776cf2890e356e4ab104b9df846e169da3eb5b0f110975547091f4e51854e \ - --hash=sha256:8e26a075fa9945b9e44a3d02cc83d776c3b76bb1ff4b133bbfa620d5650131da \ - --hash=sha256:91b916d495db3e1b473c7c8e68733beec4dce8e487442db61764fff94f59740e \ - --hash=sha256:948dfc62683a6947b9b486c4598d8f6e3ecc542478b6767b87d52be68aeb55c6 \ - --hash=sha256:982d07727c809b42a3968785354f11c3728414e4e90af0754345b431b2c32561 \ - --hash=sha256:9a094ed44a22f9da497453137c3118b531fd783866ab524b0b0fc146e7395e1d \ - --hash=sha256:9cd5b6805396157b4cf993a6940cbb8663161f29b4df2458c1c9991f099299c5 \ - --hash=sha256:9d028d189d8f38d7ff292f22187c0df37f2317f554d2ed9a2908ada330af57c0 \ - --hash=sha256:9dc55698737aca028848bde418d6c51d74f2a5fd44872d3c8b56b626729adb89 \ - --hash=sha256:9e9aaef25a40d1f1e1bbb1d0eb0190c4a64a7a1750f7eb67b8399bed6f4fd2a6 \ - --hash=sha256:a2d6d30be35ddd70ce0f8ee259a4c25f24d6d689a45a5ac440f03e6bcc5a21d1 \ - --hash=sha256:a68b637451d64ba30ed8ae125c973fa834cc2d37dfa7f154c2b479015d477ba8 \ - --hash=sha256:a72ecf5bfd3fc8d57927f7e3ded2487e144472f39010c3acaec3f6f3ff53f361 \ - --hash=sha256:aa34473fbcc108fea403074f3f45091461b18b2047d136f16ffaa4c65ad46a68 \ - --hash=sha256:ab2fb1f7a2deb4ca3ddebbae6b93905d21480a3b4e11de28d79d9fb0d316fcf8 \ - --hash=sha256:ab39d2c967aae3b48a412bff9cdbe7cd7559cd1e277599aceaeada7bc82b7200 \ - --hash=sha256:b04583e8867136ae66353fa274f45121ab3ec3166dc45aaff3655a5db90d9f0e \ - --hash=sha256:b1963ec5ba4d52788fb0eac6aca6eb8040e8e318c7e47ebbdfc09440c802919c \ - --hash=sha256:b56416091bfd7a429f958f69aaf6823c517be9a49cb5bf1daa3767ce8bf8095e \ - --hash=sha256:b862572b7a5f5ed47d2ba5921e63bf8d9e3b682f859d8f11e0e5ca46f7e82173 \ - --hash=sha256:b96341cb29a3faa5db05aff29c77d141d827414f145330e5d8846892119351c1 \ - --hash=sha256:bb52e10e453b5493afe1f7702a2973bc10f4dd8901c0f2ed869ffaa3f8319296 \ - --hash=sha256:bb5aab464a0c5e03a97abad5bdf54517061ebbf72340d576e99ff661a42575cc \ - --hash=sha256:be4223af640d0aa04c05db81d5d96ada3ead9c09187d892fd37f4f97829480be \ - --hash=sha256:c2cbd385d82f63bb35edb60b09b08abad3619bd0a4a492ae59e55afaf98e1b9d \ - --hash=sha256:c57b6ad3f7a1bdd101b2966f29dc161adf49727b1e8d3e1e89db2eda8a75c344 \ - --hash=sha256:c622f4c638a725c39abcb2e680b1bd592663c83b672a4ed350a17f806d75618e \ - --hash=sha256:cae27622c094558e519abf3242cf4272db961d12c5c9a9ffb7a1b44b2627d5c6 \ - --hash=sha256:cfcec18f7da682c4e2d82112829ce906569cb8d69fa6c26f3a50dfbed5ceb682 \ - --hash=sha256:cfeb11990f59e59a0df26c648f0adfcbf27be77241250636f5769eb08db662be \ - --hash=sha256:d0834c84ae8750ae1c4cede59b0afd4d2f775be958e11b18a3eea24ed9d0d9f1 \ - --hash=sha256:d3c75d57a00109255e60bc9c623b6ececaf7905eaab845c79f036670ed4750a2 \ - --hash=sha256:d3e10779f60c000213a5b53f518824bd07b3dc119333b26d70c6be1c27b5c794 \ - --hash=sha256:d50714405845c1010c871098558cfe5718fe39d2a2fab5f95c8863caeb7a82b3 \ - --hash=sha256:da6ef4cb8d457aab0482b50120136ae94238aaa421863eaa7d599759742c72d6 \ - --hash=sha256:dd3b6d97beb39afb412f2c79522b9e099463c31f4c49ab8347c5a2ca3531c478 \ - --hash=sha256:dd7715817a187edd7e2a2390908757f7ba42148e59cad755fb8ee1160c628eca \ - --hash=sha256:e21e888a6b471b2bb1cdd4247e8d86632672232f29be583e7eafaa5f4634d34c \ - --hash=sha256:e37aba1994d73b4944053ab65a15f313bd5c28c885dd7f0d494a11749d89db6e \ - --hash=sha256:e54e088dc64dd2766014e7cfe5f8bc45399400fd486816e494f93e3f0f55da06 \ - --hash=sha256:e6b6a11bf898cca3ce7bfaa17b646901107f3975677fbd5097f36e5eb5641983 \ - --hash=sha256:eac1207936555aa691ce32df1432b478f2729d54e6d93a1f4db9215bcd8eb47d \ - --hash=sha256:ebbf0d83ed5271991d666e54bb6c90ac2c55fb2ef3a88740c6af85dc85de2402 \ - --hash=sha256:ec1c44cf9bd22079aac37a07cb49a29ced9050ab5bddf24e50aba298f1e34d90 \ - --hash=sha256:ecae626449d00db8c08f8f1fc00047a32d6d7eb5402b3976f5c3fda2b80a7a4f \ - --hash=sha256:ed7c886a2fcbf14493ceaf9579394b33521730c161ebb8dad7db9c3e9fcab1a8 \ - --hash=sha256:ee877b6d78f9dff1da94fef51ae8cf9cce0967e043fdcc864c40b85cf293c192 \ - --hash=sha256:f0192e5f1cfc70e3cb35347135dd02e7497b3e7d83e378aa226d8b3e53a93f19 \ - --hash=sha256:f3463a5f26be513a49e4d497debcf1b252a2db7b92c77d89621aa90b83d2dd38 \ - --hash=sha256:f6222cafe00e072bb2b8f14142cd969637411fbc4dd3b1d73a90a3b817fa046f \ - --hash=sha256:f988a1cec68058f71a38471813fba9e87dffe855582682e8a10e40ece12567a2 \ - --hash=sha256:fadb07dbe36a541283ff454b1a268afd54b077d917043f2e1e5615372cb5f200 \ - --hash=sha256:fe7ff456c22725c9d9017f7a2a7df2b51af6df77314176760b22e2d05278e181 +regex==2026.7.19 \ + --hash=sha256:062f8cb7a9739c4835d22bd96f370c59aba89f257adcfa53be3cc209e08d3ae0 \ + --hash=sha256:064f1760a5a4ade65c5419be23e782f29147528e8a66e0c42dd4cedb8d4e9fc6 \ + --hash=sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62 \ + --hash=sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af \ + --hash=sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc \ + --hash=sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13 \ + --hash=sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd \ + --hash=sha256:1123ef4211d763ee771d47916a1596e2f4915794f7aabdc1adcb20e4249a6951 \ + --hash=sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc \ + --hash=sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511 \ + --hash=sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12 \ + --hash=sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518 \ + --hash=sha256:1c398716054621aa300b3d411f467dda903806c5da0df6945ab73982b8d115db \ + --hash=sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae \ + --hash=sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009 \ + --hash=sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986 \ + --hash=sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1 \ + --hash=sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a \ + --hash=sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2 \ + --hash=sha256:2955907b7157a6660f27079edf7e0229e9c9c5325c77a2ef6a890cba91efa6f0 \ + --hash=sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78 \ + --hash=sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d \ + --hash=sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4 \ + --hash=sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0 \ + --hash=sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11 \ + --hash=sha256:343a4504e3fb688c47cad451221ca5d4814f42b1e16c0065bde9cbf7f473bd52 \ + --hash=sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e \ + --hash=sha256:3d3143f159261b1ce5b24c261c590e5913370c3200c5e9ebbb92b5aa5e111902 \ + --hash=sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11 \ + --hash=sha256:4458124d71339f505bf1fb94f69fd1bb8fa9d2481eebfef27c10ef4f2b9e12f6 \ + --hash=sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba \ + --hash=sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e \ + --hash=sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac \ + --hash=sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939 \ + --hash=sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb \ + --hash=sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc \ + --hash=sha256:52579c60a6078be70a0e49c81d6e56d677f34cd439af281a0083b8c7bc75c095 \ + --hash=sha256:555497390743af1a65045fa4527782d10ff5b88970359412baa4a1e628fe393b \ + --hash=sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b \ + --hash=sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220 \ + --hash=sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c \ + --hash=sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae \ + --hash=sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3 \ + --hash=sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44 \ + --hash=sha256:5ebee1ee89c39c953baac6924fcde08c5bb427c4057510862f9d7c7bdb3d8665 \ + --hash=sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5 \ + --hash=sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97 \ + --hash=sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218 \ + --hash=sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864 \ + --hash=sha256:64729333167c2dcaaa56a331d40ee097bd9c5617ffd51dabb09eaddafb1b532e \ + --hash=sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4 \ + --hash=sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda \ + --hash=sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459 \ + --hash=sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18 \ + --hash=sha256:6e44c0e7c5664be20aee92085153150c0a7967310a73a43c0f832b7cd35d0dd3 \ + --hash=sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5 \ + --hash=sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a \ + --hash=sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035 \ + --hash=sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa \ + --hash=sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5 \ + --hash=sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78 \ + --hash=sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20 \ + --hash=sha256:89dfee3319f5ae3f75ebd5c2445a809bb320252ba5529ffdafea4ef25d79cf1a \ + --hash=sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a \ + --hash=sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a \ + --hash=sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965 \ + --hash=sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5 \ + --hash=sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797 \ + --hash=sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276 \ + --hash=sha256:98c6ac18480fcdb33f35439183f1d2e79760ab41930309c6d951cb1f8e46694c \ + --hash=sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547 \ + --hash=sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9 \ + --hash=sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d \ + --hash=sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1 \ + --hash=sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68 \ + --hash=sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a \ + --hash=sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd \ + --hash=sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c \ + --hash=sha256:b2b506b1788df5fecd270a10d5e70a95fe77b87ea2b370a318043f6f5f817ee6 \ + --hash=sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82 \ + --hash=sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7 \ + --hash=sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15 \ + --hash=sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e \ + --hash=sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38 \ + --hash=sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96 \ + --hash=sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2 \ + --hash=sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8 \ + --hash=sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732 \ + --hash=sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966 \ + --hash=sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053 \ + --hash=sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3 \ + --hash=sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0 \ + --hash=sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f \ + --hash=sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e \ + --hash=sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327 \ + --hash=sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac \ + --hash=sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6 \ + --hash=sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2 \ + --hash=sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a \ + --hash=sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435 \ + --hash=sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5 \ + --hash=sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d \ + --hash=sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312 \ + --hash=sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b \ + --hash=sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40 \ + --hash=sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974 \ + --hash=sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404 \ + --hash=sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff \ + --hash=sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf \ + --hash=sha256:fbf300e2070bb35038660b3be1be4b91b0024edb41517e6996320b49b92b4175 \ + --hash=sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da \ + --hash=sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d \ + --hash=sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1 \ + --hash=sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2 # via dateparser requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ @@ -1836,9 +1836,9 @@ scramp==1.4.12 \ # via # ocotilloapi # pg8000 -sentry-sdk==2.65.0 \ - --hash=sha256:3595169677a808e4d0e1ea6ffb89443459549c7a98392ed71c77c847182ab6bf \ - --hash=sha256:c94dc945d54bad49d4f20448b1e6b217ca2f92f46d05c3e83d41764af685c3d1 +sentry-sdk==2.66.0 \ + --hash=sha256:096136c214c602be2b323524d30755dc5b30ec5a218a206207f33b12c05c6f11 \ + --hash=sha256:9727d35aa83c56cd53294676fe65b96296a334c9ce107fa2142bd70f47acb265 # via ocotilloapi shapely==2.1.2 \ --hash=sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9 \ @@ -1950,17 +1950,17 @@ starlette==1.3.1 \ # fastapi # ocotilloapi # starlette-admin -starlette-admin==0.17.0 \ - --hash=sha256:ccc8229a8224d3da3b3cb4ef410c264eaedccc05d4d2f1ae5281374208110456 \ - --hash=sha256:fe3d29dfc4073ba6e5a3eb68110aa87a7ce18f735a9e8df8bd9663907b153f03 +starlette-admin==0.17.1 \ + --hash=sha256:685615945d55de636879e3523ec70a6419176f7cd6f04a17470e35670f96b972 \ + --hash=sha256:7bdeaf1c30fd9036ef3779fb0255002d3d18aaf6f7e674200e21c604ee563fc7 # via ocotilloapi tinydb==4.8.2 \ --hash=sha256:f7dfc39b8d7fda7a1ca62a8dbb449ffd340a117c1206b68c50b1a481fb95181d \ --hash=sha256:f97030ee5cbc91eeadd1d7af07ab0e48ceb04aa63d4a983adbaca4cba16e86c3 # via pygeoapi -typer==0.26.8 \ - --hash=sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c \ - --hash=sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e +typer==0.27.0 \ + --hash=sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5 \ + --hash=sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1 # via ocotilloapi types-pytz==2025.2.0.20250809 \ --hash=sha256:222e32e6a29bb28871f8834e8785e3801f2dc4441c715cd2082b271eecbe21e5 \ @@ -2019,60 +2019,111 @@ werkzeug==3.1.8 \ --hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \ --hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44 # via flask -yarl==1.24.2 \ - --hash=sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b \ - --hash=sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc \ - --hash=sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8 \ - --hash=sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461 \ - --hash=sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44 \ - --hash=sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b \ - --hash=sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9 \ - --hash=sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd \ - --hash=sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67 \ - --hash=sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420 \ - --hash=sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50 \ - --hash=sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b \ - --hash=sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488 \ - --hash=sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536 \ - --hash=sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a \ - --hash=sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa \ - --hash=sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f \ - --hash=sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe \ - --hash=sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761 \ - --hash=sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57 \ - --hash=sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14 \ - --hash=sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd \ - --hash=sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656 \ - --hash=sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992 \ - --hash=sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1 \ - --hash=sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf \ - --hash=sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024 \ - --hash=sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986 \ - --hash=sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb \ - --hash=sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543 \ - --hash=sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed \ - --hash=sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617 \ - --hash=sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8 \ - --hash=sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3 \ - --hash=sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535 \ - --hash=sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630 \ - --hash=sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215 \ - --hash=sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592 \ - --hash=sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf \ - --hash=sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0 \ - --hash=sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92 \ - --hash=sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1 \ - --hash=sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8 \ - --hash=sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1 \ - --hash=sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a \ - --hash=sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d \ - --hash=sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208 \ - --hash=sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0 \ - --hash=sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607 \ - --hash=sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8 \ - --hash=sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2 \ - --hash=sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056 \ - --hash=sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14 +yarl==1.24.5 \ + --hash=sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36 \ + --hash=sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331 \ + --hash=sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871 \ + --hash=sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498 \ + --hash=sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780 \ + --hash=sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027 \ + --hash=sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224 \ + --hash=sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144 \ + --hash=sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76 \ + --hash=sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3 \ + --hash=sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb \ + --hash=sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740 \ + --hash=sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec \ + --hash=sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e \ + --hash=sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a \ + --hash=sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24 \ + --hash=sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950 \ + --hash=sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95 \ + --hash=sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb \ + --hash=sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928 \ + --hash=sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c \ + --hash=sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41 \ + --hash=sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e \ + --hash=sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550 \ + --hash=sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f \ + --hash=sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b \ + --hash=sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e \ + --hash=sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede \ + --hash=sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad \ + --hash=sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6 \ + --hash=sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104 \ + --hash=sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2 \ + --hash=sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba \ + --hash=sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9 \ + --hash=sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12 \ + --hash=sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1 \ + --hash=sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027 \ + --hash=sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385 \ + --hash=sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2 \ + --hash=sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840 \ + --hash=sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2 \ + --hash=sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4 \ + --hash=sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc \ + --hash=sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c \ + --hash=sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213 \ + --hash=sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba \ + --hash=sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448 \ + --hash=sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0 \ + --hash=sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6 \ + --hash=sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966 \ + --hash=sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750 \ + --hash=sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621 \ + --hash=sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13 \ + --hash=sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0 \ + --hash=sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58 \ + --hash=sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54 \ + --hash=sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075 \ + --hash=sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4 \ + --hash=sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440 \ + --hash=sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f \ + --hash=sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4 \ + --hash=sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c \ + --hash=sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f \ + --hash=sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d \ + --hash=sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7 \ + --hash=sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9 \ + --hash=sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723 \ + --hash=sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047 \ + --hash=sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da \ + --hash=sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293 \ + --hash=sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b \ + --hash=sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61 \ + --hash=sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca \ + --hash=sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed \ + --hash=sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a \ + --hash=sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a \ + --hash=sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688 \ + --hash=sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16 \ + --hash=sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d \ + --hash=sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077 \ + --hash=sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce \ + --hash=sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd \ + --hash=sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d \ + --hash=sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88 \ + --hash=sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5 \ + --hash=sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75 \ + --hash=sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1 \ + --hash=sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f \ + --hash=sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b \ + --hash=sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff \ + --hash=sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9 \ + --hash=sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f \ + --hash=sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0 \ + --hash=sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5 \ + --hash=sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25 \ + --hash=sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6 \ + --hash=sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00 \ + --hash=sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373 \ + --hash=sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd \ + --hash=sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1 \ + --hash=sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2 \ + --hash=sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d \ + --hash=sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba \ + --hash=sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104 # via # aiohttp # ocotilloapi diff --git a/uv.lock b/uv.lock index af5d19844..a22cbf5f8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "affine" @@ -198,11 +202,11 @@ fastapi = [ [[package]] name = "asgiref" -version = "3.11.1" +version = "3.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, ] [[package]] @@ -794,7 +798,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.139.0" +version = "0.139.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -803,9 +807,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, + { 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" }, ] [[package]] @@ -950,7 +954,7 @@ wheels = [ [[package]] name = "google-api-core" -version = "2.31.0" +version = "2.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, @@ -959,22 +963,22 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/22/155cadf1d49272a9cf48f3168c0f3874fa13397297e611a5ea00cd093880/google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2", size = 176492, upload-time = "2026-06-03T14:52:17.257Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/40/9bdbb60b03a332bd45acb8703da08bbc27d991d35286b62e42acc86d243a/google_api_core-2.31.0-py3-none-any.whl", hash = "sha256:ef79fb3784c71cbac89cbd03301ba0c8fb8ad2aa95d7f9204dd9628f7adf59ab", size = 173102, upload-time = "2026-06-03T14:51:26.729Z" }, + { 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" }, ] [[package]] name = "google-auth" -version = "2.55.2" +version = "2.56.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/b9/e370d86fea3da13ec0256df30323dd26c0cb9c8c85f0c6ec42ac9df0106b/google_auth-2.55.2.tar.gz", hash = "sha256:97ae7790ff740f2bc9db60eb864a7804f4ac19f5f02c38b3d942f2fea6e9b9ae", size = 361414, upload-time = "2026-07-07T18:43:21.227Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/c6/02eb5a337ac316a4c30c012e747bad5cea36e1a876efecdf80865541f7d8/google_auth-2.55.2-py3-none-any.whl", hash = "sha256:d715f265f2cafc6a5f1bf0dc19870d20e3119f6f6682785a250bce3d03d38a3b", size = 256778, upload-time = "2026-07-07T18:43:19.52Z" }, + { 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" }, ] [[package]] @@ -992,7 +996,7 @@ wheels = [ [[package]] name = "google-cloud-storage" -version = "3.12.1" +version = "3.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -1002,9 +1006,9 @@ dependencies = [ { name = "google-resumable-media" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/ac/60b4cb0a6c8c6bb7cedb8971ba5e34a94096acf76e2cc242bcf1e6fc5c49/google_cloud_storage-3.12.1.tar.gz", hash = "sha256:1d81491c7663bc26c5056d00b834356f2253b910ef467f9cf9928a87fca1e04b", size = 17339353, upload-time = "2026-07-08T17:03:59.142Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/6e/ca176e95bafac0fe7befeee7e0420e686de147571cd2908e308c5fe71bda/google_cloud_storage-3.12.1-py3-none-any.whl", hash = "sha256:9297ae0c2ce3f5400b1f2bb3a3e6d2cd256614366e03cd30600871df8e903afb", size = 340845, upload-time = "2026-07-08T17:03:31.418Z" }, + { 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" }, ] [[package]] @@ -1625,7 +1629,7 @@ requires-dist = [ { name = "annotated-types", specifier = "==0.7.0" }, { name = "anyio", specifier = "==4.14.2" }, { name = "apitally", extras = ["fastapi"], specifier = "==0.25.1" }, - { name = "asgiref", specifier = "==3.11.1" }, + { name = "asgiref", specifier = "==3.12.1" }, { name = "asn1crypto", specifier = "==1.5.1" }, { name = "asyncpg", specifier = "==0.31.0" }, { name = "attrs", specifier = "==26.1.0" }, @@ -1641,14 +1645,14 @@ 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.139.0" }, + { name = "fastapi", specifier = "==0.139.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.31.0" }, - { name = "google-auth", specifier = "==2.55.2" }, + { name = "google-api-core", specifier = "==2.32.0" }, + { name = "google-auth", specifier = "==2.56.0" }, { name = "google-cloud-core", specifier = "==2.6.0" }, - { name = "google-cloud-storage", specifier = "==3.12.1" }, + { 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" }, @@ -1681,7 +1685,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.4" }, + { name = "pygeoapi", specifier = "==0.23.5" }, { name = "pygments", specifier = "==2.20.0" }, { name = "pyjwt", specifier = "==2.13.0" }, { name = "pymssql", specifier = ">=2.3.13" }, @@ -1694,7 +1698,7 @@ requires-dist = [ { 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.65.0" }, + { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.66.0" }, { name = "shapely", specifier = "==2.1.2" }, { name = "six", specifier = "==1.17.0" }, { name = "sniffio", specifier = "==1.3.1" }, @@ -1704,15 +1708,15 @@ 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.0" }, - { name = "typer", specifier = "==0.26.8" }, + { 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" }, { name = "tzdata", specifier = "==2025.3" }, { name = "urllib3", specifier = "==2.7.0" }, { name = "utm", specifier = "==0.8.1" }, { name = "uvicorn", specifier = "==0.51.0" }, - { name = "yarl", specifier = "==1.24.2" }, + { name = "yarl", specifier = "==1.24.5" }, ] [package.metadata.requires-dev] @@ -2247,7 +2251,7 @@ wheels = [ [[package]] name = "pygeoapi" -version = "0.23.4" +version = "0.23.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -2269,9 +2273,9 @@ dependencies = [ { name = "sqlalchemy" }, { name = "tinydb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/82/5b73362b8674ae070e611e21c0f12f4684e07e6a2e3b2a169d074f9873b0/pygeoapi-0.23.4.tar.gz", hash = "sha256:935a22761eb0d8736f7b0f2c8384672f5341577509803e35f33f6e78299221ae", size = 371713, upload-time = "2026-04-27T19:56:36.488Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/b1/1bcb134b3834d06878417968a0d70a9b7a9a41a713a867d156d3c24b9997/pygeoapi-0.23.4-py2.py3-none-any.whl", hash = "sha256:7f0fd854575a0da049b64907b56fc0f77ab97768414c1397897e60a0e563438d", size = 577911, upload-time = "2026-04-27T19:56:34.609Z" }, + { 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" }, ] [[package]] @@ -2785,15 +2789,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.65.0" +version = "2.66.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/1f/ed17a390348156ca99fe622b97cd7d2f1969b5f49df89084b0f28e7953e9/sentry_sdk-2.65.0.tar.gz", hash = "sha256:c94dc945d54bad49d4f20448b1e6b217ca2f92f46d05c3e83d41764af685c3d1", size = 932133, upload-time = "2026-07-13T11:33:19.92Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/3b/326ad4c03b5da89b5124c8890af66e8119c4d2e10abc0619e0d67d9f7c7f/sentry_sdk-2.65.0-py3-none-any.whl", hash = "sha256:3595169677a808e4d0e1ea6ffb89443459549c7a98392ed71c77c847182ab6bf", size = 503869, upload-time = "2026-07-13T11:33:17.71Z" }, + { 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" }, ] [package.optional-dependencies] @@ -2965,16 +2969,16 @@ wheels = [ [[package]] name = "starlette-admin" -version = "0.17.0" +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/05/8b/fd9fb6165f280105fc2cba6582143f88e2ffd10546cda9b33b42be44819b/starlette_admin-0.17.0.tar.gz", hash = "sha256:ccc8229a8224d3da3b3cb4ef410c264eaedccc05d4d2f1ae5281374208110456", size = 2106612, upload-time = "2026-07-12T00:07:48.237Z" } +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/eb/ac/9d4141d12cef26c37143c7e4975f4d60383c1883adaa590b7dbdf163c33a/starlette_admin-0.17.0-py3-none-any.whl", hash = "sha256:fe3d29dfc4073ba6e5a3eb68110aa87a7ce18f735a9e8df8bd9663907b153f03", size = 2183316, upload-time = "2026-07-12T00:07:49.973Z" }, + { 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] @@ -2993,7 +2997,7 @@ wheels = [ [[package]] name = "typer" -version = "0.26.8" +version = "0.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -3001,9 +3005,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, + { 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" }, ] [[package]] @@ -3116,67 +3120,67 @@ wheels = [ [[package]] name = "yarl" -version = "1.24.2" +version = "1.24.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, - { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, - { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, - { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, - { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, - { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, - { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, - { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, - { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, - { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, - { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, - { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, - { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, - { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, - { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, - { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, - { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, - { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, - { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, - { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, - { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, - { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, - { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, - { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, - { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, - { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, - { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, - { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, - { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, - { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, - { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, - { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, - { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, ] [[package]] From 250096a0fea96bf6a18e4993fabb97857071d195 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:23:25 +0000 Subject: [PATCH 152/160] build(deps): bump tzdata from 2025.3 to 2026.3 Bumps [tzdata](https://github.com/python/tzdata) from 2025.3 to 2026.3. - [Release notes](https://github.com/python/tzdata/releases) - [Changelog](https://github.com/python/tzdata/blob/master/NEWS.md) - [Commits](https://github.com/python/tzdata/compare/2025.3...2026.3) --- updated-dependencies: - dependency-name: tzdata dependency-version: '2026.3' 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 c7fc3a5d9..e3b1ef910 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,7 @@ dependencies = [ "typer==0.27.0", "typing-extensions==4.16.0", "typing-inspection==0.4.2", - "tzdata==2025.3", + "tzdata==2026.3", "urllib3==2.7.0", "utm==0.8.1", "uvicorn==0.51.0", diff --git a/requirements.txt b/requirements.txt index 7d019c1ad..eef572485 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1989,9 +1989,9 @@ typing-inspection==0.4.2 \ # fastapi # ocotilloapi # pydantic -tzdata==2025.3 \ - --hash=sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1 \ - --hash=sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7 +tzdata==2026.3 \ + --hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \ + --hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931 # via # ocotilloapi # pandas diff --git a/uv.lock b/uv.lock index a22cbf5f8..a3c6bb6d5 100644 --- a/uv.lock +++ b/uv.lock @@ -1712,7 +1712,7 @@ requires-dist = [ { name = "typer", specifier = "==0.27.0" }, { name = "typing-extensions", specifier = "==4.16.0" }, { name = "typing-inspection", specifier = "==0.4.2" }, - { name = "tzdata", specifier = "==2025.3" }, + { name = "tzdata", specifier = "==2026.3" }, { name = "urllib3", specifier = "==2.7.0" }, { name = "utm", specifier = "==0.8.1" }, { name = "uvicorn", specifier = "==0.51.0" }, @@ -3042,11 +3042,11 @@ wheels = [ [[package]] name = "tzdata" -version = "2025.3" +version = "2026.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] [[package]] From 4f8094150d78fb9632697e970f1177073d27e55f Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 20 Jul 2026 14:32:06 -0700 Subject: [PATCH 153/160] chore: sync uv.lock root version to 1.1.5 Same stale-root-version issue as the production back-merge: uv.lock had 1.1.0 while pyproject.toml is 1.1.5, failing `uv sync --locked` in CI. Regenerated. Co-Authored-By: Claude Opus 4.8 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 791d52f03..9d74208ac 100644 --- a/uv.lock +++ b/uv.lock @@ -1479,7 +1479,7 @@ wheels = [ [[package]] name = "ocotilloapi" -version = "1.1.0" +version = "1.1.5" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From d96f394c34355912368973b5fb9e10f4c95b00e5 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 20 Jul 2026 14:50:51 -0700 Subject: [PATCH 154/160] fix(db): dedupe NMW locations in measurement views, re-register pg_cron job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the three confirmed review findings on #787. That PR is a pure hotfix back-merge (release-flow.md §3 step 4), so the fixes land here on staging instead. Duplicate feature ids in the measurement views ---------------------------------------------- NMW_WellLocations is keyed on OBJECTID, not WellDataID, so one well can carry several location rows. d1e2f3a4b5c6 already accounts for this with a DISTINCT ON CTE, but the four views in e2f3a4b5c6d7 join the table directly and fan a single measurement into N rows sharing one OBJECTID. New revision a5b6c7d8e9f0 recreates all four with the same deduped CTE and tie-break, so both view families resolve a multi-location well identically. The Exclude = 0 predicate moves inside the CTE for the two views using it -- filtering after the dedup would let DISTINCT ON settle on an excluded row and drop the well rather than fall through to the next eligible location. Verified against a scratch database: column signatures are identical across all four views, and a well with two location rows plus one BHT measurement returns 2 rows before and 1 after. Nightly matview refresh never scheduled in production ----------------------------------------------------- x2y3z4a5b6c7 is gated on ENABLE_PG_CRON, which was set on staging's copy of CD_production.yml but never reached the production/hotfix line. When that revision shipped in v1.1.2 the production migration ran with the flag unset, returned early, and Alembic stamped it -- so refresh_materialized_views() and the cron job were never created, and setting the variable now cannot re-run it. Deploy-time refresh has been masking this; only the nightly cadence is missing. New revision b6c7d8e9f0a1 re-runs the registration under a fresh id. The workflow half already exists on staging and reaches production via the next promotion. Also corrects the stale "Revises:" docstring in c0d1e2f3a4b5 (said t6u7v8w9x0y1, down_revision is x2y3z4a5b6c7 -- docstring only, the graph was always correct). Co-Authored-By: Claude Opus 4.8 --- ...dupe_nmw_locations_in_measurement_views.py | 353 ++++++++++++++++++ ...chedule_nightly_matview_refresh_pg_cron.py | 114 ++++++ .../c0d1e2f3a4b5_nmw_mirror_tables.py | 2 +- 3 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py create mode 100644 alembic/versions/b6c7d8e9f0a1_reschedule_nightly_matview_refresh_pg_cron.py diff --git a/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py b/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py new file mode 100644 index 000000000..3d3b07b71 --- /dev/null +++ b/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py @@ -0,0 +1,353 @@ +"""dedupe NMW_WellLocations in the measurement OGC views + +Revision ID: a5b6c7d8e9f0 +Revises: y3z4a5b6c7d8 +Create Date: 2026-07-20 + +``NMW_WellLocations`` is keyed on ``OBJECTID``, not ``WellDataID``, so a single +well can carry several location rows. The per-well geothermal views +(``d1e2f3a4b5c6``) already account for this with a ``DISTINCT ON ("WellDataID")`` +CTE, but the four measurement views created in ``e2f3a4b5c6d7`` join +``NMW_WellLocations`` directly. Where a well has more than one location row, +that join fans a single measurement into N rows sharing the same ``OBJECTID`` +-- which pygeoapi surfaces as duplicate feature ids. + +This revision recreates all four views with the same deduped-location CTE the +geothermal views use. ``e2f3a4b5c6d7`` is left untouched: it is already applied +in production, so the fix has to arrive as a new revision. + +The tie-break (``ORDER BY "WellDataID", "OBJECTID"``) matches ``d1e2f3a4b5c6`` +so both view families resolve a multi-location well to the same row. + +Only the location join changes. Column lists, unit conversions, join order, and +the remaining filters are carried over from ``e2f3a4b5c6d7`` verbatim. Note +that the ``Exclude = 0`` predicate moves *inside* the CTE for the two views +that use it: filtering after the dedup would let ``DISTINCT ON`` settle on an +excluded row and drop the well entirely, instead of falling through to the next +eligible location. +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision: str = "a5b6c7d8e9f0" +down_revision: Union[str, Sequence[str], None] = "y3z4a5b6c7d8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_BHT_MEAS_VIEW = "ogc_bht_measurements" +_TEMP_DEPTH_VIEW = "ogc_temp_depth_measurements" +_HEAT_FLOW_VIEW = "ogc_heat_flow" +_DST_VIEW = "ogc_dst" + +# One location row per well. Two variants because ogc_temp_depth_measurements +# and ogc_heat_flow additionally require Exclude = 0, which has to be applied +# before the dedup (see module docstring). +_LOC_CTE = """ + SELECT DISTINCT ON ("WellDataID") + "WellDataID", "County", "State", + "Lat_dd27", "Long_dd27", "Lat_dd83", "Long_dd83", "LocAccVal" + FROM "NMW_WellLocations" + WHERE "Lat_dd83" IS NOT NULL + AND "Long_dd83" IS NOT NULL + ORDER BY "WellDataID", "OBJECTID" +""" + +_LOC_CTE_NOT_EXCLUDED = """ + SELECT DISTINCT ON ("WellDataID") + "WellDataID", "County", "State", + "Lat_dd27", "Long_dd27", "Lat_dd83", "Long_dd83", "LocAccVal" + FROM "NMW_WellLocations" + WHERE "Exclude" = 0 + AND "Lat_dd83" IS NOT NULL + AND "Long_dd83" IS NOT NULL + ORDER BY "WellDataID", "OBJECTID" +""" + +_COORDS_PRESENT = """ + WHERE loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL +""" + +_NOT_EXCLUDED_AND_COORDS_PRESENT = """ + WHERE loc."Exclude" = 0 + AND loc."Lat_dd83" IS NOT NULL + AND loc."Long_dd83" IS NOT NULL +""" + + +def _loc_parts(deduped: bool, exclude_filter: bool, left_join: bool = False): + """Location CTE / join / trailing-filter fragments for one view. + + ``deduped=True`` is this revision's behavior; ``deduped=False`` reproduces + the direct join from ``e2f3a4b5c6d7`` so downgrade is faithful. + """ + if deduped: + cte = _LOC_CTE_NOT_EXCLUDED if exclude_filter else _LOC_CTE + # The dedup CTE already applies both predicates internally, so no + # trailing WHERE is needed. A LEFT JOIN would be pointless here: the + # original's WHERE on loc columns made it an inner join in practice. + return f"WITH loc AS ({cte})", 'JOIN loc ON loc."WellDataID"', "" + + join_kw = "LEFT JOIN" if left_join else "JOIN" + where = _NOT_EXCLUDED_AND_COORDS_PRESENT if exclude_filter else _COORDS_PRESENT + return "", f'{join_kw} "NMW_WellLocations" AS loc ON loc."WellDataID"', where + + +def _recreate_views(deduped: bool) -> None: + # ogc_bht_measurements + cte, loc_join, loc_where = _loc_parts(deduped, exclude_filter=False) + op.execute(text(f'DROP VIEW IF EXISTS "{_BHT_MEAS_VIEW}"')) + op.execute( + text( + f""" + CREATE VIEW "{_BHT_MEAS_VIEW}" AS + {cte} + SELECT + d."OBJECTID" AS id, + hdr."API" AS api, + hdr."CurWellNam" AS well_name, + hdr."CurWellNum" AS well_num, + hdr."CurOperatr" AS operator, + hdr."WellType" AS well_type, + hdr."Well_TVD" AS well_tvd, + hdr."ComplDate" AS completion_date, + hdr."CurStatus" AS current_status, + hdr."TotalDepth" AS total_depth, + hdr."Cuttings" AS cuttings, + hdr."CoreExists" AS core_exists, + loc."County" AS county, + d."Depth" AS bht_depth, + d."BHT" AS bht, + d."HrsSnceCir" AS hours_since_circulation, + d."DateMeasrd" AS date_measured, + ST_SetSRID( + ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326 + ) AS geom + FROM "NMW_GtBhtData" AS d + JOIN "NMW_GtBhtHeaders" AS bh ON bh."BHTGUID" = d."BHTGUID" + JOIN "NMW_WellSamples" AS s ON s."SamplSetID" = bh."SamplSetID" + JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = s."RecrdsetID" + JOIN "NMW_WellZDatum" AS z ON z."RecrdsetID" = r."RecrdSetID" + JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + {loc_join} = r."WellDataID" + {loc_where} + """ + ) + ) + + # ogc_temp_depth_measurements + cte, loc_join, loc_where = _loc_parts(deduped, exclude_filter=True) + op.execute(text(f'DROP VIEW IF EXISTS "{_TEMP_DEPTH_VIEW}"')) + op.execute( + text( + f""" + CREATE VIEW "{_TEMP_DEPTH_VIEW}" AS + {cte} + SELECT + td."OBJECTID" AS id, + hdr."CurWellNam" AS well_name, + hdr."CurWellNum" AS well_num, + hdr."API" AS api, + r."SourceID" AS source_id, + s."SampleFm" AS sample_fm, + loc."County" AS county, + loc."State" AS state, + loc."Lat_dd27" AS lat_dd27, + loc."Long_dd27" AS long_dd27, + loc."Lat_dd83" AS lat_dd83, + loc."Long_dd83" AS long_dd83, + loc."LocAccVal" AS loc_acc_val, + s."EnteredBy" AS entered_by, + s."EntryDate" AS entry_date, + td."Depth" AS depth, + s."SmpDpUnt" AS depth_unit, + td."Temp" AS temp, + td."TempUnit" AS temp_unit, + z."Elev_GL" AS elev_gl, + z."Elev_unspc" AS elev_unspc, + z."Elev_KB" AS elev_kb, + s."SampleDate" AS sample_date, + 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 "NMW_WellZDatum" AS z ON z."RecrdsetID" = r."RecrdSetID" + JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + {loc_join} = r."WellDataID" + {loc_where} + """ + ) + ) + + # ogc_heat_flow + cte, loc_join, loc_where = _loc_parts(deduped, exclude_filter=True) + op.execute(text(f'DROP VIEW IF EXISTS "{_HEAT_FLOW_VIEW}"')) + op.execute( + text( + f""" + CREATE VIEW "{_HEAT_FLOW_VIEW}" AS + {cte} + SELECT + shf."OBJECTID" AS id, + hdr."CurWellNam" AS well_name, + hdr."CurWellNum" AS well_num, + hdr."API" AS api, + loc."County" AS county, + loc."State" AS state, + loc."Lat_dd27" AS lat_dd27, + loc."Long_dd27" AS long_dd27, + loc."Lat_dd83" AS lat_dd83, + loc."Long_dd83" AS long_dd83, + r."SourceID" AS source_id, + z."Elev_GL" AS elev_gl, + z."Elev_KB" AS elev_kb, + z."Elev_unspc" AS elev_unspc, + CASE WHEN z."DepthUnits" = 'ft' + THEN 0.3048 * z."Elev_unspc" + ELSE z."Elev_unspc" + END AS elevation_m, + z."DepthUnits" AS depth_units, + hdr."TotalDepth" AS total_depth, + CASE WHEN z."DepthUnits" = 'ft' + THEN 0.3048 * hdr."TotalDepth" + ELSE hdr."TotalDepth" + END AS total_depth_m, + shf."FromDepth" AS from_depth, + shf."ToDepth" AS to_depth, + shf."ThermlCond" AS therml_cond, + shf."TCondRange" AS tcond_range, + shf."TCondError" AS tcond_error, + shf."TCondUnit" AS tcond_unit, + CASE WHEN shf."TCondUnit" = 'TCU' + THEN 0.4184 * shf."ThermlCond" + ELSE shf."ThermlCond" + END AS tc_si, + shf."SampleType" AS sample_type, + shf."NumSamples" AS num_samples, + shf."ThermlGrad" AS therml_grad, + shf."TGradRange" AS tgrad_range, + shf."TGError" AS tg_error, + shf."GradUnit" AS grad_unit, + shf."HeatFlow" AS heat_flow, + shf."HtFlowUnit" AS ht_flow_unit, + CASE WHEN shf."HtFlowUnit" = 'HFU' + THEN 41.84 * shf."HeatFlow" + ELSE shf."HeatFlow" + END AS heat_flow_si, + shf."Quality" AS quality, + src."FirstAuth" AS first_auth, + src."PubYear" AS pub_year, + src."Title" AS title, + src."Journal" AS journal, + src."Volume" AS volume, + src."PageNo" AS page_no, + shf."HtFlowEst" AS ht_flow_est, + r."EntryDate" AS entry_date, + CASE WHEN shf."HtFlowUnit" = 'HFU' + THEN 41.84 * shf."HtFlowEst" + ELSE shf."HtFlowEst" + END AS ht_flow_est_si, + ST_SetSRID( + ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326 + ) AS geom + FROM "NMW_GtSumHeatFlow" AS shf + JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = shf."RecrdSetID" + JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + {loc_join} = r."WellDataID" + LEFT JOIN "NMW_WellZDatum" AS z ON z."RecrdsetID" = r."RecrdSetID" + JOIN "NMW_Sources" AS src ON src."SourceID" = r."SourceID" + {loc_where} + """ + ) + ) + + # ogc_dst -- the only view with a second CTE, so the location CTE has to be + # spliced into the same WITH clause rather than prefixed. + cte, loc_join, loc_where = _loc_parts(deduped, exclude_filter=False, left_join=True) + with_clause = ( + f"WITH loc AS ({_LOC_CTE}), flow_history AS (" + if deduped + else "WITH flow_history AS (" + ) + op.execute(text(f'DROP VIEW IF EXISTS "{_DST_VIEW}"')) + op.execute( + text( + f""" + CREATE VIEW "{_DST_VIEW}" AS + {with_clause} + SELECT + "DSTInterval", + string_agg("Operation", '; ' ORDER BY "OBJECTID") AS flow_history + FROM "NMW_WsDstFlowHistory" + GROUP BY "DSTInterval" + ) + SELECT DISTINCT + i."OBJECTID" AS id, + hdr."CurWellNam" AS well_name, + hdr."CurWellNum" AS well_num, + hdr."API" AS api, + i."DSTName" AS dst_name, + dh."DSTOprator" AS dst_operator, + i."DSTNumber" AS dst_number, + i."DSTDate" AS dst_date, + loc."County" AS county, + loc."State" AS state, + loc."Lat_dd83" AS lat_dd83, + loc."Long_dd83" AS long_dd83, + s."From_Depth" AS from_depth, + s."To_Depth" AS to_depth, + i."TargetFm" AS target_fm, + i."PackrFrom" AS packer_from, + i."PackerTo" AS packer_to, + i."SrfChokeSz" AS srf_choke_sz, + i."BotChokeSz" AS bot_choke_sz, + s."SmpDpUnt" AS depth_unit, + z."Elev_GL" AS elev_gl, + z."Elev_unspc" AS elev_unspc, + p."PrsGageDpt" AS prs_gage_dpt, + i."PipeDia" AS pipe_dia, + i."PipeLength" AS pipe_length, + fh.flow_history AS flow_history, + p."PrsInShtIn" AS init_flow, + p."FlwPrsInMin" AS flw_prs_in_min, + p."PrsFnShtIn" AS fin_flow, + p."FlwPrsFinMin" AS flw_prs_fin_min, + p."PrsInitClsdIn" AS prs_init_clsd_in, + p."InShtInMin" AS in_sht_in_min, + p."EquilPress" AS fin_shut_in, + p."FnShtInMin" AS fn_sht_in_min, + p."HydrostPrsIn" AS hydrost_prs_in, + p."HydStPrsFl" AS hyd_st_prs_fl, + dh."PressUnits" AS press_units, + p."BlankedOff" AS blanked_off, + p."FmTemp" AS fm_temp, + ST_SetSRID( + ST_MakePoint(loc."Long_dd83", loc."Lat_dd83"), 4326 + ) AS geom + FROM "NMW_WsDstIntervals" AS i + JOIN "NMW_WsDstHeaders" AS dh ON dh."DSTGUID" = i."DSTGUID" + JOIN "NMW_WellSamples" AS s ON s."SamplSetID" = dh."SamplSetID" + JOIN "NMW_WellRecords" AS r ON r."RecrdSetID" = s."RecrdsetID" + JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" + {loc_join} = r."WellDataID" + LEFT JOIN "NMW_WellZDatum" AS z ON z."RecrdsetID" = r."RecrdSetID" + LEFT JOIN "NMW_WsDstPressure" AS p ON p."DSTInterval" = i."DSTInterval" + LEFT JOIN flow_history AS fh ON fh."DSTInterval" = i."DSTInterval" + {loc_where} + """ + ) + ) + + +def upgrade() -> None: + _recreate_views(deduped=True) + + +def downgrade() -> None: + _recreate_views(deduped=False) diff --git a/alembic/versions/b6c7d8e9f0a1_reschedule_nightly_matview_refresh_pg_cron.py b/alembic/versions/b6c7d8e9f0a1_reschedule_nightly_matview_refresh_pg_cron.py new file mode 100644 index 000000000..1016b1caf --- /dev/null +++ b/alembic/versions/b6c7d8e9f0a1_reschedule_nightly_matview_refresh_pg_cron.py @@ -0,0 +1,114 @@ +"""re-register the nightly pg_cron materialized-view refresh + +Revision ID: b6c7d8e9f0a1 +Revises: a5b6c7d8e9f0 +Create Date: 2026-07-20 + +``x2y3z4a5b6c7`` registers the nightly refresh job, but is a no-op unless +``ENABLE_PG_CRON`` is truthy. That variable was set on the ``staging`` copy of +``CD_production.yml`` and never reached the ``production`` / ``hotfix/v*`` +line, so when ``x2y3z4a5b6c7`` shipped in ``v1.1.2`` the production migration +step ran it with the flag unset: it returned early and Alembic stamped the +revision as applied. ``refresh_materialized_views()`` and the cron job were +never created, and because the revision is stamped, setting the variable does +not cause it to re-run. + +This revision re-runs the registration under a fresh revision id so the +already-stamped one is not in the way. It is deliberately a near-duplicate of +``x2y3z4a5b6c7`` rather than an import of it -- migrations stay self-contained +and immutable. + +Still gated on ``ENABLE_PG_CRON`` for the same reason as the original: pg_cron +needs ``shared_preload_libraries``, which the development, test, and CI +Postgres images do not provide. Everything here is idempotent, so it is safe +on a database where the job already exists. See +``docs/pg_cron-nightly-refresh.md``. +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import text + +from services.env import get_bool_env + +# revision identifiers, used by Alembic. +revision: str = "b6c7d8e9f0a1" +down_revision: Union[str, Sequence[str], None] = "a5b6c7d8e9f0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Must match x2y3z4a5b6c7 -- this re-registers that same job, it does not add +# a second one. +CRON_JOB_NAME = "refresh-materialized-views" +CRON_SCHEDULE = "0 9 * * *" + +_REFRESH_FUNCTION_SQL = r""" +CREATE OR REPLACE FUNCTION public.refresh_materialized_views() +RETURNS void +LANGUAGE plpgsql +AS $func$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT matviewname + FROM pg_matviews + WHERE schemaname = 'public' + ORDER BY matviewname + LOOP + EXECUTE format('REFRESH MATERIALIZED VIEW %I', r.matviewname); + END LOOP; +END; +$func$; +""" + + +def _pg_cron_enabled() -> bool: + """pg_cron is only wired up where the server explicitly enables it.""" + return get_bool_env("ENABLE_PG_CRON", False) is True + + +def upgrade() -> None: + if not _pg_cron_enabled(): + print( + "ENABLE_PG_CRON is not set; skipping pg_cron job registration " + "(expected in development, test, and CI)." + ) + return + + bind = op.get_bind() + + op.execute(text("CREATE EXTENSION IF NOT EXISTS pg_cron")) + op.execute(text(_REFRESH_FUNCTION_SQL)) + + # Drop any job already carrying this name so re-running does not accumulate + # duplicate schedules. + op.execute( + text( + "SELECT cron.unschedule(jobid) FROM cron.job WHERE jobname = :name" + ).bindparams(name=CRON_JOB_NAME) + ) + + bind.execute( + text("SELECT cron.schedule(:name, :sched, :cmd)").bindparams( + name=CRON_JOB_NAME, + sched=CRON_SCHEDULE, + cmd="SELECT public.refresh_materialized_views();", + ) + ) + + +def downgrade() -> None: + if not _pg_cron_enabled(): + print("ENABLE_PG_CRON is not set; nothing to unschedule.") + return + + # Only the schedule is removed. refresh_materialized_views() is left in + # place -- x2y3z4a5b6c7 also claims ownership of it, and dropping it here + # would break that revision's view of the world. + op.execute( + text( + "SELECT cron.unschedule(jobid) FROM cron.job WHERE jobname = :name" + ).bindparams(name=CRON_JOB_NAME) + ) diff --git a/alembic/versions/c0d1e2f3a4b5_nmw_mirror_tables.py b/alembic/versions/c0d1e2f3a4b5_nmw_mirror_tables.py index f59a760ca..b35f7c268 100644 --- a/alembic/versions/c0d1e2f3a4b5_nmw_mirror_tables.py +++ b/alembic/versions/c0d1e2f3a4b5_nmw_mirror_tables.py @@ -1,7 +1,7 @@ """NMW staging mirror tables and FK constraints Revision ID: c0d1e2f3a4b5 -Revises: t6u7v8w9x0y1 +Revises: x2y3z4a5b6c7 Create Date: 2026-06-22 1:1 staging mirror of the legacy NM_Wells SQL Server tables needed for the From c1b29d42031b3ad325f3c26d0e4e53a0ce22bc8a Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:53:22 +0000 Subject: [PATCH 155/160] Formatting changes --- ...dupe_nmw_locations_in_measurement_views.py | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py b/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py index 3d3b07b71..0452e9cf0 100644 --- a/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py +++ b/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py @@ -101,9 +101,7 @@ def _recreate_views(deduped: bool) -> None: # ogc_bht_measurements cte, loc_join, loc_where = _loc_parts(deduped, exclude_filter=False) op.execute(text(f'DROP VIEW IF EXISTS "{_BHT_MEAS_VIEW}"')) - op.execute( - text( - f""" + op.execute(text(f""" CREATE VIEW "{_BHT_MEAS_VIEW}" AS {cte} SELECT @@ -135,16 +133,12 @@ def _recreate_views(deduped: bool) -> None: JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" {loc_join} = r."WellDataID" {loc_where} - """ - ) - ) + """)) # ogc_temp_depth_measurements cte, loc_join, loc_where = _loc_parts(deduped, exclude_filter=True) op.execute(text(f'DROP VIEW IF EXISTS "{_TEMP_DEPTH_VIEW}"')) - op.execute( - text( - f""" + op.execute(text(f""" CREATE VIEW "{_TEMP_DEPTH_VIEW}" AS {cte} SELECT @@ -181,16 +175,12 @@ def _recreate_views(deduped: bool) -> None: JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" {loc_join} = r."WellDataID" {loc_where} - """ - ) - ) + """)) # ogc_heat_flow cte, loc_join, loc_where = _loc_parts(deduped, exclude_filter=True) op.execute(text(f'DROP VIEW IF EXISTS "{_HEAT_FLOW_VIEW}"')) - op.execute( - text( - f""" + op.execute(text(f""" CREATE VIEW "{_HEAT_FLOW_VIEW}" AS {cte} SELECT @@ -263,9 +253,7 @@ def _recreate_views(deduped: bool) -> None: LEFT JOIN "NMW_WellZDatum" AS z ON z."RecrdsetID" = r."RecrdSetID" JOIN "NMW_Sources" AS src ON src."SourceID" = r."SourceID" {loc_where} - """ - ) - ) + """)) # ogc_dst -- the only view with a second CTE, so the location CTE has to be # spliced into the same WITH clause rather than prefixed. @@ -276,9 +264,7 @@ def _recreate_views(deduped: bool) -> None: else "WITH flow_history AS (" ) op.execute(text(f'DROP VIEW IF EXISTS "{_DST_VIEW}"')) - op.execute( - text( - f""" + op.execute(text(f""" CREATE VIEW "{_DST_VIEW}" AS {with_clause} SELECT @@ -340,9 +326,7 @@ def _recreate_views(deduped: bool) -> None: LEFT JOIN "NMW_WsDstPressure" AS p ON p."DSTInterval" = i."DSTInterval" LEFT JOIN flow_history AS fh ON fh."DSTInterval" = i."DSTInterval" {loc_where} - """ - ) - ) + """)) def upgrade() -> None: From beeb4adb7e924e9b98b0239577b2a41bd02bfd5e Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 20 Jul 2026 16:12:44 -0700 Subject: [PATCH 156/160] fix(chemistry): fail loudly when Drive sync is misconfigured Two misconfigurations produced misleading results instead of an actionable error. An unset GCS_BUCKET_NAME reached the storage client as an empty bucket name and raised "IndexError: string index out of range" from inside _validate_name, with a full traceback and no indication of the cause. It fired on --dry-run, which is the first command in the runbook, so a half-configured .env broke the documented starting point. Validate the name up front and raise ChemistryDriveConfigError, matching how a missing folder id is already handled. More seriously, Drive answers notFound for a folder the caller cannot see, so an unshared folder was indistinguishable from an empty one: the sync reported files_seen=0 and exited 0. A missing or revoked share therefore read as "nothing new to ingest" while real lab batches sat unprocessed, and the failed-file retry path never engaged because nothing was ever seen as failed. Probe the folder with files().get() before listing and turn 403/404 into a config error. Other HTTP errors still propagate -- a 500 is a transient Drive fault, not a misconfiguration, and reporting it as one would misdirect the operator. Co-Authored-By: Claude Opus 4.8 --- services/chemistry_drive.py | 47 ++++++++++++++++++-- tests/test_chemistry_drive.py | 83 +++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/services/chemistry_drive.py b/services/chemistry_drive.py index b6f5f2ea8..f32ce1d02 100644 --- a/services/chemistry_drive.py +++ b/services/chemistry_drive.py @@ -60,7 +60,7 @@ class ChemistryDriveConfigError(Exception): - """The Drive folder is not configured (missing folder id).""" + """The ingest is not configured (missing folder id or manifest bucket).""" # --- Google Drive access ------------------------------------------------------- @@ -93,9 +93,34 @@ def get_drive_service(): return build("drive", "v3", credentials=_drive_credentials(), cache_discovery=False) +def assert_folder_accessible(folder_id: str, service=None) -> None: + """Fail loudly when the folder is missing or not shared with our identity. + + Drive answers ``notFound`` for a folder the caller cannot see, so an + unshared folder is indistinguishable from an empty one on a plain list + call. Without this check a missing share reads as "nothing new to + ingest" and real lab batches sit unprocessed with a zero exit code. + """ + from googleapiclient.errors import HttpError + + service = service or get_drive_service() + try: + service.files().get( + fileId=folder_id, fields="id", supportsAllDrives=True + ).execute() + except HttpError as exc: + if exc.resp.status in (403, 404): + raise ChemistryDriveConfigError( + f"Drive folder {folder_id!r} is not accessible. Check the folder id, " + "and that the folder is shared with the account running the ingest." + ) from exc + raise + + def list_drive_xlsx(folder_id: str, service=None) -> list[dict]: """List non-trashed ``.xlsx`` files directly under ``folder_id``.""" service = service or get_drive_service() + assert_folder_accessible(folder_id, service=service) query = ( f"'{folder_id}' in parents " "and trashed = false " @@ -146,8 +171,22 @@ def _manifest_path() -> str: return os.environ.get("CHEMISTRY_INGEST_MANIFEST_PATH", DEFAULT_MANIFEST_PATH) +def _manifest_bucket(): + """Resolve the manifest bucket, failing cleanly when it is unconfigured. + + ``get_storage_bucket`` raises an opaque ``IndexError`` from deep inside the + storage client when the bucket name is empty, so check it here first. + """ + if not (os.environ.get("GCS_BUCKET_NAME") or "").strip(): + raise ChemistryDriveConfigError( + "No manifest bucket configured. Set GCS_BUCKET_NAME to the bucket " + "holding the chemistry ingest manifest." + ) + return get_storage_bucket() + + def load_manifest(bucket=None) -> dict[str, dict]: - bucket = bucket or get_storage_bucket() + bucket = bucket or _manifest_bucket() blob = bucket.blob(_manifest_path()) if not blob.exists(): return {} @@ -159,7 +198,7 @@ def load_manifest(bucket=None) -> dict[str, dict]: def save_manifest(manifest: dict[str, dict], bucket=None) -> None: - bucket = bucket or get_storage_bucket() + bucket = bucket or _manifest_bucket() blob = bucket.blob(_manifest_path()) blob.upload_from_string( json.dumps(manifest, indent=2, sort_keys=True), @@ -225,7 +264,7 @@ def sync_and_ingest( "No Drive folder configured. Set CHEMISTRY_DRIVE_FOLDER_ID or pass --folder-id." ) - bucket = bucket or get_storage_bucket() + bucket = bucket or _manifest_bucket() manifest = load_manifest(bucket) files = list_drive_xlsx(folder_id, service=drive_service) diff --git a/tests/test_chemistry_drive.py b/tests/test_chemistry_drive.py index 2ad2769fb..12a672c55 100644 --- a/tests/test_chemistry_drive.py +++ b/tests/test_chemistry_drive.py @@ -16,6 +16,9 @@ """Tests for the Drive-polling chemistry sync (services/chemistry_drive.py).""" import io +import sys +import types +from types import SimpleNamespace import pytest from openpyxl import Workbook @@ -26,6 +29,7 @@ from services import chemistry_drive from services.chemistry_drive import ( ChemistryDriveConfigError, + assert_folder_accessible, load_manifest, save_manifest, sync_and_ingest, @@ -138,6 +142,85 @@ def test_missing_folder_raises(monkeypatch): sync_and_ingest(folder_id=None) +@pytest.mark.parametrize("bucket_name", ["", " "]) +def test_missing_manifest_bucket_raises(monkeypatch, bucket_name): + """An unset GCS_BUCKET_NAME must fail as a config error, not an IndexError.""" + monkeypatch.setenv("GCS_BUCKET_NAME", bucket_name) + with pytest.raises(ChemistryDriveConfigError, match="GCS_BUCKET_NAME"): + sync_and_ingest(folder_id="folder") + + +def test_dry_run_missing_manifest_bucket_raises(monkeypatch): + """The dry run reads the manifest too, so it fails the same clean way.""" + monkeypatch.delenv("GCS_BUCKET_NAME", raising=False) + with pytest.raises(ChemistryDriveConfigError, match="GCS_BUCKET_NAME"): + sync_and_ingest(folder_id="folder", dry_run=True) + + +# ------------------------- folder access tests ------------------------------- + + +class _FakeHttpError(Exception): + """Stand-in for googleapiclient.errors.HttpError with a status code.""" + + def __init__(self, status): + super().__init__(f"HTTP {status}") + self.resp = SimpleNamespace(status=status) + + +class _FakeFilesApi: + def __init__(self, error=None): + self._error = error + self.get_called_with = None + + def get(self, **kwargs): + self.get_called_with = kwargs + + def _execute(): + if self._error: + raise self._error + return {"id": kwargs.get("fileId")} + + return SimpleNamespace(execute=_execute) + + +class _FakeDriveService: + def __init__(self, error=None): + self.files_api = _FakeFilesApi(error) + + def files(self): + return self.files_api + + +@pytest.fixture() +def _fake_http_error(monkeypatch): + """Make the service module treat _FakeHttpError as googleapiclient's.""" + module = types.ModuleType("googleapiclient.errors") + module.HttpError = _FakeHttpError + monkeypatch.setitem(sys.modules, "googleapiclient.errors", module) + + +@pytest.mark.parametrize("status", [403, 404]) +def test_unshared_folder_raises_config_error(_fake_http_error, status): + """Drive returns notFound for an unshared folder; that must not look empty.""" + service = _FakeDriveService(error=_FakeHttpError(status)) + with pytest.raises(ChemistryDriveConfigError, match="not accessible"): + assert_folder_accessible("folder", service=service) + + +def test_accessible_folder_passes(_fake_http_error): + service = _FakeDriveService() + assert_folder_accessible("folder", service=service) is None + assert service.files_api.get_called_with["fileId"] == "folder" + + +def test_unexpected_http_error_is_not_swallowed(_fake_http_error): + """A 500 is a transient Drive fault, not a misconfiguration -- let it bubble.""" + service = _FakeDriveService(error=_FakeHttpError(500)) + with pytest.raises(_FakeHttpError): + assert_folder_accessible("folder", service=service) + + # ------------------------- sync tests ---------------------------------------- From 7fc86d0841d8d35ffbd363695edc46c2f60ca2c8 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 20 Jul 2026 16:12:58 -0700 Subject: [PATCH 157/160] fix(chemistry): treat trailing letters in SamplePointID as a sample point A PointID ending in letters is a sample point, never a base well id: WL-0434A is a sample point on well WL-0434. The ingest treated the whole value as the base, so a workbook naming WL-0434A either failed to resolve a well (the common case, since wells are named WL-0434) or, if such a Thing existed, appended a second letter and produced WL-0434AA. Split the supplied PointID into base and suffix, resolve the well from the base, and compare any supplied letter against the next free incrementor. The computed letter wins because it cannot collide with an existing sample point, but a disagreement is now reported so a human can reconcile it. Lowercase endings are not incrementors, so a well legitimately named "Test Well" is unaffected. Reporting a disagreement needed a non-fatal channel: validation_errors aborts the file, which is too blunt for a letter that is merely unexpected. Add a warnings list to the payload, surfaced by the CLI and counted in the manifest's existing validation_errors_or_warnings field, leaving the exit code at 0. Verified against a real LIMS export (NMT_260503): 104 rows, 98 imported after collapsing Fe/Mn/Sr duplicate methods, loading as WL-0433A and WL-0434A rather than WL-0433AA/WL-0434AA. Co-Authored-By: Claude Opus 4.8 --- cli/cli.py | 7 +++++ services/chemistry_lims.py | 54 ++++++++++++++++++++++++++++++-- tests/test_chemistry_lims.py | 60 ++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/cli/cli.py b/cli/cli.py index 907437928..b4ff204c6 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -991,6 +991,7 @@ def water_chemistry_bulk_upload( payload = result.payload if isinstance(result.payload, dict) else {} summary = payload.get("summary", {}) validation_errors = payload.get("validation_errors", []) + warnings = payload.get("warnings", []) created_samples = payload.get("created_samples", []) skipped_duplicates = payload.get("skipped_duplicates", []) @@ -1046,6 +1047,12 @@ def water_chemistry_bulk_upload( ) typer.echo() + if warnings: + typer.secho("WARNINGS (loaded, but check these)", fg=colors["field"], bold=True) + for entry in warnings: + typer.secho(f" - {entry}", fg=colors["field"]) + typer.echo() + if validation_errors: typer.secho("VALIDATION", fg=colors["accent"], bold=True) typer.secho( diff --git a/services/chemistry_lims.py b/services/chemistry_lims.py index d5e6c8b20..665a447f0 100644 --- a/services/chemistry_lims.py +++ b/services/chemistry_lims.py @@ -362,6 +362,23 @@ def keyf(r: dict) -> tuple[str, str, str]: _SUFFIX_RE_TEMPLATE = r"^{base}([A-Z]+)$" +# A PointID ending in letters is a *sample point* id, never a base well id: +# ``WL-0434A`` is a sample point on well ``WL-0434``. The base must therefore +# end in a non-letter (``WL-0434``, ``MG-030``) for the trailing letters to +# count as an incrementor. +_POINTID_SUFFIX_RE = re.compile(r"^(?P.*[^A-Z])(?P[A-Z]+)$") + + +def split_pointid(pointid: str) -> tuple[str, str | None]: + """Split a PointID into its base well id and any supplied letter suffix. + + ``WL-0434A`` -> ``("WL-0434", "A")``; ``WL-0434`` -> ``("WL-0434", None)``. + """ + match = _POINTID_SUFFIX_RE.match(pointid) + if not match: + return pointid, None + return match.group("base"), match.group("suffix") + def _resolve_thing_id(session: Session, pointid: str) -> int | None: things = session.scalars(select(Thing).where(Thing.name == pointid)).all() @@ -496,7 +513,22 @@ def bulk_upload_chemistry( prepped = dedupe_records(prepped) + warnings: list[str] = [] + with session_ctx() as session: + # A workbook's SamplePointID may already carry a sample-point letter + # (WL-0434A). The well is always the base (WL-0434), so strip it before + # resolving; the supplied letter is checked against the computed one + # below. + supplied_suffixes: dict[str, str | None] = {} + for rec in prepped: + base, suffix = split_pointid(rec["samplepointid"]) + rec["samplepointid"] = base + # Keep the first supplied suffix seen for the well; a workbook + # should not disagree with itself, and if it does the mismatch + # warning below still fires. + supplied_suffixes.setdefault(base, suffix) + # Resolve every distinct (base) sample point to a Thing up front. base_pointids = sorted({r["samplepointid"] for r in prepped}) thing_ids: dict[str, int | None] = { @@ -549,7 +581,19 @@ def bucket_key(r: dict) -> tuple[str, str | None]: max(used_suffixes[thing_id]) + 1 if used_suffixes[thing_id] else 1 ) used_suffixes[thing_id].add(next_int) - sample_point_id = f"{base}{_int_to_suffix(next_int)}" + computed_suffix = _int_to_suffix(next_int) + sample_point_id = f"{base}{computed_suffix}" + + # The workbook may have supplied its own letter. The computed one + # wins (it cannot collide with an existing sample point), but a + # disagreement is surfaced so a human can reconcile it. + supplied = supplied_suffixes.get(base) + if supplied is not None and supplied != computed_suffix: + warnings.append( + f"{base}: workbook supplied sample point {base}{supplied}, " + f"but the next free incrementor is {computed_suffix}; " + f"loaded as {sample_point_id}." + ) collection_date = next( (r["sample_date"] for r in recs if r["sample_date"]), None @@ -585,6 +629,7 @@ def bucket_key(r: dict) -> tuple[str, str | None]: validation_errors=validation_errors, skipped_duplicates=skipped_duplicates, created=created, + warnings=warnings, ) @@ -595,8 +640,10 @@ def _result( validation_errors: list[str], skipped_duplicates: list[dict], created: list[dict], + warnings: list[str] | None = None, ) -> ChemistryUploadResult: - rows_with_issues = len(validation_errors) + len(skipped_duplicates) + warnings = warnings or [] + rows_with_issues = len(validation_errors) + len(skipped_duplicates) + len(warnings) payload = { "summary": { "total_rows_processed": processed, @@ -606,12 +653,15 @@ def _result( "samples_skipped": len(skipped_duplicates), }, "validation_errors": validation_errors, + "warnings": warnings, "skipped_duplicates": skipped_duplicates, "created_samples": created, } stderr_parts: list[str] = [] if validation_errors: stderr_parts.append("\n".join(validation_errors)) + if warnings: + stderr_parts.append("\n".join(warnings)) if skipped_duplicates: dupes = ", ".join( f"{d['pointid']} (WCLab_ID {d['wclab_id']})" for d in skipped_duplicates diff --git a/tests/test_chemistry_lims.py b/tests/test_chemistry_lims.py index c95d9575f..086383b5f 100644 --- a/tests/test_chemistry_lims.py +++ b/tests/test_chemistry_lims.py @@ -33,6 +33,7 @@ bulk_upload_chemistry, dedupe_records, prep_record, + split_pointid, ) LIMS_HEADER = [ @@ -204,6 +205,65 @@ def test_bulk_upload_skips_duplicate_lab_sample( assert len(rows_ca) == 1 # not duplicated +@pytest.mark.parametrize( + "pointid,expected", + [ + ("WL-0434", ("WL-0434", None)), + ("WL-0434A", ("WL-0434", "A")), + ("WL-0434AB", ("WL-0434", "AB")), + ("MG-030", ("MG-030", None)), + ("MG-030A", ("MG-030", "A")), + # Lowercase is not an incrementor, so a name ending in one is a base. + ("Test Well", ("Test Well", None)), + ("Test WellA", ("Test Well", "A")), + ], +) +def test_split_pointid(pointid, expected): + """A PointID ending in capitals is a sample point; the well is the base.""" + assert split_pointid(pointid) == expected + + +def test_bulk_upload_strips_supplied_suffix_to_find_the_well( + tmp_path, water_well_thing, _cleanup_chemistry +): + """A workbook naming sample point 'Test WellA' resolves to well 'Test Well'.""" + _write_workbook( + tmp_path / "lims.xlsx", + [_lims_row("calcium", "12.5", pointid="Test WellA", SampleNumber="LAB-1")], + ) + + result = bulk_upload_chemistry(tmp_path / "lims.xlsx") + + assert result.exit_code == 0, result.stderr + # Not 'Test WellAA' -- the supplied letter is not doubled. + assert result.payload["created_samples"][0]["sample_point_id"] == "Test WellA" + assert result.payload["warnings"] == [] + + +def test_bulk_upload_warns_when_supplied_suffix_disagrees( + tmp_path, water_well_thing, _cleanup_chemistry +): + """Computed letter wins; the disagreement is reported but does not fail.""" + _write_workbook( + tmp_path / "first.xlsx", + [_lims_row("calcium", "12.5", pointid="Test WellA", SampleNumber="LAB-1")], + ) + bulk_upload_chemistry(tmp_path / "first.xlsx") + + # A second lab sample still labelled 'A', though 'B' is the next free one. + _write_workbook( + tmp_path / "second.xlsx", + [_lims_row("calcium", "9.9", pointid="Test WellA", SampleNumber="LAB-2")], + ) + result = bulk_upload_chemistry(tmp_path / "second.xlsx") + + assert result.exit_code == 0, result.stderr + assert result.payload["created_samples"][0]["sample_point_id"] == "Test WellB" + warnings = result.payload["warnings"] + assert len(warnings) == 1 + assert "Test WellA" in warnings[0] and "Test WellB" in warnings[0] + + def test_bulk_upload_appends_new_lab_sample_with_next_suffix( tmp_path, water_well_thing, _cleanup_chemistry ): From eb89d046651b3517bf2299df635842d8747cfc20 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 20 Jul 2026 16:52:16 -0700 Subject: [PATCH 158/160] fix(db): reparent a5b6c7d8e9f0 onto the EDR views revision staging carried two alembic heads. z9a0b1c2d3e4 (add EDR water views, 7b322330) and a5b6c7d8e9f0 (dedupe NMW locations, d96f394c) both declared y3z4a5b6c7d8 as their parent, forking the graph. "alembic upgrade head" then aborted with "Multiple head revisions are present", which killed the BDD before_all hook before any schema existed -- every downstream "relation ... does not exist" failure followed from that, and all 180 scenarios went untested. Chain a5b6c7d8e9f0 onto z9a0b1c2d3e4 so the graph is linear again. The only semantic change is down_revision; the rest of the diff is black reformatting the op.execute(text(...)) calls, which landed unformatted on staging. No SQL is altered. Reparenting rather than adding a merge revision keeps the history linear, but it assumes no database has already applied a5b6c7d8e9f0 or b6c7d8e9f0a1 without also applying z9a0b1c2d3e4. A database at b6c7d8e9f0a1 would now consider z9a0b1c2d3e4 applied when it is not, and would silently lack the EDR views. Verify staging and production before deploying; a fresh database is unaffected. Verified on a clean database: single head, single alembic_version row, and ogc_waterlevels / ogc_water_chemistry both present. BDD suite passes 57 scenarios (was 0 of 180); unit suite 747 passed. Co-Authored-By: Claude Opus 4.8 --- ...dupe_nmw_locations_in_measurement_views.py | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py b/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py index 0452e9cf0..ee95ae50c 100644 --- a/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py +++ b/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py @@ -1,7 +1,7 @@ """dedupe NMW_WellLocations in the measurement OGC views Revision ID: a5b6c7d8e9f0 -Revises: y3z4a5b6c7d8 +Revises: z9a0b1c2d3e4 Create Date: 2026-07-20 ``NMW_WellLocations`` is keyed on ``OBJECTID``, not ``WellDataID``, so a single @@ -34,7 +34,7 @@ # revision identifiers, used by Alembic. revision: str = "a5b6c7d8e9f0" -down_revision: Union[str, Sequence[str], None] = "y3z4a5b6c7d8" +down_revision: Union[str, Sequence[str], None] = "z9a0b1c2d3e4" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -101,7 +101,9 @@ def _recreate_views(deduped: bool) -> None: # ogc_bht_measurements cte, loc_join, loc_where = _loc_parts(deduped, exclude_filter=False) op.execute(text(f'DROP VIEW IF EXISTS "{_BHT_MEAS_VIEW}"')) - op.execute(text(f""" + op.execute( + text( + f""" CREATE VIEW "{_BHT_MEAS_VIEW}" AS {cte} SELECT @@ -133,12 +135,16 @@ def _recreate_views(deduped: bool) -> None: JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" {loc_join} = r."WellDataID" {loc_where} - """)) + """ + ) + ) # ogc_temp_depth_measurements cte, loc_join, loc_where = _loc_parts(deduped, exclude_filter=True) op.execute(text(f'DROP VIEW IF EXISTS "{_TEMP_DEPTH_VIEW}"')) - op.execute(text(f""" + op.execute( + text( + f""" CREATE VIEW "{_TEMP_DEPTH_VIEW}" AS {cte} SELECT @@ -175,12 +181,16 @@ def _recreate_views(deduped: bool) -> None: JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" {loc_join} = r."WellDataID" {loc_where} - """)) + """ + ) + ) # ogc_heat_flow cte, loc_join, loc_where = _loc_parts(deduped, exclude_filter=True) op.execute(text(f'DROP VIEW IF EXISTS "{_HEAT_FLOW_VIEW}"')) - op.execute(text(f""" + op.execute( + text( + f""" CREATE VIEW "{_HEAT_FLOW_VIEW}" AS {cte} SELECT @@ -253,7 +263,9 @@ def _recreate_views(deduped: bool) -> None: LEFT JOIN "NMW_WellZDatum" AS z ON z."RecrdsetID" = r."RecrdSetID" JOIN "NMW_Sources" AS src ON src."SourceID" = r."SourceID" {loc_where} - """)) + """ + ) + ) # ogc_dst -- the only view with a second CTE, so the location CTE has to be # spliced into the same WITH clause rather than prefixed. @@ -264,7 +276,9 @@ def _recreate_views(deduped: bool) -> None: else "WITH flow_history AS (" ) op.execute(text(f'DROP VIEW IF EXISTS "{_DST_VIEW}"')) - op.execute(text(f""" + op.execute( + text( + f""" CREATE VIEW "{_DST_VIEW}" AS {with_clause} SELECT @@ -326,7 +340,9 @@ def _recreate_views(deduped: bool) -> None: LEFT JOIN "NMW_WsDstPressure" AS p ON p."DSTInterval" = i."DSTInterval" LEFT JOIN flow_history AS fh ON fh."DSTInterval" = i."DSTInterval" {loc_where} - """)) + """ + ) + ) def upgrade() -> None: From 4e49ab48c75336e95ba54d227788c4079168ba5d Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:52:49 +0000 Subject: [PATCH 159/160] Formatting changes --- ...dupe_nmw_locations_in_measurement_views.py | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py b/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py index ee95ae50c..5be0d5af6 100644 --- a/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py +++ b/alembic/versions/a5b6c7d8e9f0_dedupe_nmw_locations_in_measurement_views.py @@ -101,9 +101,7 @@ def _recreate_views(deduped: bool) -> None: # ogc_bht_measurements cte, loc_join, loc_where = _loc_parts(deduped, exclude_filter=False) op.execute(text(f'DROP VIEW IF EXISTS "{_BHT_MEAS_VIEW}"')) - op.execute( - text( - f""" + op.execute(text(f""" CREATE VIEW "{_BHT_MEAS_VIEW}" AS {cte} SELECT @@ -135,16 +133,12 @@ def _recreate_views(deduped: bool) -> None: JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" {loc_join} = r."WellDataID" {loc_where} - """ - ) - ) + """)) # ogc_temp_depth_measurements cte, loc_join, loc_where = _loc_parts(deduped, exclude_filter=True) op.execute(text(f'DROP VIEW IF EXISTS "{_TEMP_DEPTH_VIEW}"')) - op.execute( - text( - f""" + op.execute(text(f""" CREATE VIEW "{_TEMP_DEPTH_VIEW}" AS {cte} SELECT @@ -181,16 +175,12 @@ def _recreate_views(deduped: bool) -> None: JOIN "NMW_WellHeaders" AS hdr ON hdr."WellDataID" = r."WellDataID" {loc_join} = r."WellDataID" {loc_where} - """ - ) - ) + """)) # ogc_heat_flow cte, loc_join, loc_where = _loc_parts(deduped, exclude_filter=True) op.execute(text(f'DROP VIEW IF EXISTS "{_HEAT_FLOW_VIEW}"')) - op.execute( - text( - f""" + op.execute(text(f""" CREATE VIEW "{_HEAT_FLOW_VIEW}" AS {cte} SELECT @@ -263,9 +253,7 @@ def _recreate_views(deduped: bool) -> None: LEFT JOIN "NMW_WellZDatum" AS z ON z."RecrdsetID" = r."RecrdSetID" JOIN "NMW_Sources" AS src ON src."SourceID" = r."SourceID" {loc_where} - """ - ) - ) + """)) # ogc_dst -- the only view with a second CTE, so the location CTE has to be # spliced into the same WITH clause rather than prefixed. @@ -276,9 +264,7 @@ def _recreate_views(deduped: bool) -> None: else "WITH flow_history AS (" ) op.execute(text(f'DROP VIEW IF EXISTS "{_DST_VIEW}"')) - op.execute( - text( - f""" + op.execute(text(f""" CREATE VIEW "{_DST_VIEW}" AS {with_clause} SELECT @@ -340,9 +326,7 @@ def _recreate_views(deduped: bool) -> None: LEFT JOIN "NMW_WsDstPressure" AS p ON p."DSTInterval" = i."DSTInterval" LEFT JOIN flow_history AS fh ON fh."DSTInterval" = i."DSTInterval" {loc_where} - """ - ) - ) + """)) def upgrade() -> None: From 2e2dee1a7dd57b597a68d1c9591b37ba60e3d769 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:54:16 +0000 Subject: [PATCH 160/160] chore(staging): release 1.2.0-rc.1 --- .release-please-manifest.staging.json | 2 +- CHANGELOG-rc.md | 97 +++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/.release-please-manifest.staging.json b/.release-please-manifest.staging.json index 6362badf3..fa8324b78 100644 --- a/.release-please-manifest.staging.json +++ b/.release-please-manifest.staging.json @@ -1,3 +1,3 @@ { - ".": "1.2.0-rc" + ".": "1.2.0-rc.1" } diff --git a/CHANGELOG-rc.md b/CHANGELOG-rc.md index 996a74b8c..ffaf3b394 100644 --- a/CHANGELOG-rc.md +++ b/CHANGELOG-rc.md @@ -1,5 +1,102 @@ # Changelog +## [1.2.0-rc.1](https://github.com/DataIntegrationGroup/OcotilloAPI/compare/v1.2.0-rc...v1.2.0-rc.1) (2026-07-21) + + +### Features + +* add feedback endpoint for bug reports and feature requests (BDMS-897) ([6f840c5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/6f840c542595729276079c2562d844740ba5cb07)) +* add geothermal OGC collections and fix Docker pygeoapi config ([1bf1977](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1bf1977ec78b8c30ee77c9c68b329e4d50716581)) +* add NGWMN views sourced from new Ocotillo data model ([6ecee1c](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/6ecee1c365b3aeb71843c080c4c4cebe7f7bb524)) +* add NMW_Sources mirror table and transfer spec ([7435e6f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7435e6fb83e6dbb8baebf2abd0f7a63eff5db85a)) +* add ogc_bht_measurements OGC collection ([04b8ecb](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/04b8ecb9dbd3d22fea952ba379625b796997c926)) +* add ogc_dst OGC collection ([87c4c7f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/87c4c7f7e36b61cb0180ec3bf387e7b2a8908f5d)) +* add ogc_heat_flow OGC collection ([88bd152](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/88bd152ed3bb4b1dd5b8d705b91193b42b51f24b)) +* add ogc_temp_depth_measurements OGC collection ([e0a2491](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e0a2491d86e144a5e783f3993587854ff5048143)) +* add transducer_daily_data materialized view ([7851008](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7851008011959784a2c35d2bc73c299f528fea5a)) +* add workflow to close stale pull requests automatically ([faf5d63](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/faf5d6326c813305dc297ab4f813181ba0e6605c)) +* add workflow to close stale pull requests automatically ([10c522b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/10c522b824cc955190a3ace884d089dcaa1c0ae8)) +* allow configurable output directory for NMW CSV exports ([f550ad9](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/f550ad954e9f8fbd8b1e8d0c7bab1906a5c9f428)) +* **api/asset:** Add new endpoint to update or delete asset thing association ([64242a2](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/64242a281118191022dfda79e7f724d7ea97031f)) +* **api/asset:** add new list unassociated assets endpoint ([4283c88](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4283c88e4d006078623ea9f5af5837cc0700b998)) +* **api/asset:** add new list unassociated assets endpoint ([0e8cda1](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0e8cda1a0c3eaa6e617fb5b3368eabbbdd58b12a)) +* **api/search:** add groups/projects to search ([271f850](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/271f850c643f2a07b5fcc85ede65e19c64d135d2)) +* **api/search:** add groups/projects to search ([642a285](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/642a285c481161b8155d5f19154c26af71c46e58)) +* **assets:** add the ability to disassociate an uploaded asset from a well ([d5caa14](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d5caa14218bb9ad21316cab14e978a2649a0c824)) +* **cli:** add chemistry LIMS ingestion with Google Drive sync ([e13c5c3](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/e13c5c3a23f49a93163973f610a7ed59243314df)) +* **cli:** chemistry LIMS ingestion with Google Drive sync (BDMS-1034) ([195be59](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/195be594e542092624369e3e6db36903650f499e)) +* **db:** nightly pg_cron refresh of pygeoapi materialized views ([59d7eae](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/59d7eae776e3f053a9844b582ec0a7cc0f436e6c)) +* **db:** nightly pg_cron refresh of pygeoapi materialized views ([99a84e5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/99a84e5249e64d00abbdcf7330f9e36c2409e7dc)) +* **db:** refresh all materialized views, not just pygeoapi ([fc990af](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fc990afa14659bcd8fb3ed277d19d2ddb58a3014)) +* **edr:** implement OGC API - EDR water collections and BDD spec ([7b32233](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7b322330e4691f27ef754d36b7910e8879ca0f42)) +* **group:** add thing-to-group association routes ([1db40ae](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1db40aeca3e70b5f846353b1aea99c6db4533472)) +* **lexicon:** add 'Spanish Stirrup Rockshop' organization to lexicon ([4742c94](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/4742c942ec38d8a5f29e0a40aa70f448a86138c6)) +* **lexicon:** add new 'Spanish Stirrup Rockshop' organization term ([d227c52](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d227c52f2a93f08e5cd4c109fa38120ddbd4d10a)) +* **monitoring:** add OpenStatus monitoring-as-code config ([afd7775](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/afd77756e3c6bee57fa9260fc97259085d2b7ef2)) +* **monitoring:** add OpenStatus monitoring-as-code config ([c4f7dc7](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c4f7dc734c78f10f1f33f44c29d2a55485e313b9)) +* **notifications:** add Slack edit notifications for Ocotillo CRUD ([3210684](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/32106844d915f878f68c874dd88c0b34bee42060)) +* serve NGWMN exports from new Ocotillo-model views ([c009966](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/c009966ad89b4f4f6aaa3e42ca0cf997c706d54b)) +* serve NGWMN exports from the new Ocotillo data model ([5c82913](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/5c829137114306450ae08613d38c8dce1f1672ab)) +* source NGWMN continuous water levels from transducer_daily_data ([7036a62](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7036a629f1818ac12a8e68a74e97b36acfc22c3c)) +* **transfers:** add NM_Wells 1:1 mirror transfer (BDMS-945) ([3a9fcc5](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3a9fcc5aca7339994707248b4544757843d08a42)) + + +### Bug Fixes + +* **alembic:** merge nmw mirror chain into staging head ([9fba496](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/9fba496cef143fa2f9b129a73ef218629cab997b)) +* **api/asset:** Add delete asset notifications ([0db8c77](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0db8c77604f4301d371a1146a8d7704907a42418)) +* **api/asset:** patch delete asset notifications ([886cfc1](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/886cfc17f4b07cc38da4e701cd0021b8138cf744)) +* **api/search:** update group db schema to have a vector search ([bdadeaf](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/bdadeaffea5816ae643d92ebc04a8d76ee79519a)) +* **api:** probe database in /health so 200 proves PostGIS reachable ([fe0b626](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fe0b6260781bf4d8e84a47e4ecd4b516e22ea7af)) +* **api:** probe the database in /health so a 200 proves PostGIS is reachable ([8cc9c88](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8cc9c88ff56a116c8e6d9400ba5f069213ecdaa5)) +* apply isinstance(user, dict) guard to model_adder and model_deleter ([2d2db87](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2d2db87f88ee8d74a93e44dcfbbf83fd7b052267)) +* **chemistry:** fail loudly when Drive sync is misconfigured ([beeb4ad](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/beeb4adb7e924e9b98b0239577b2a41bd02bfd5e)) +* **chemistry:** treat trailing letters in SamplePointID as a sample point ([7fc86d0](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7fc86d0841d8d35ffbd363695edc46c2f60ca2c8)) +* **ci:** backport CD (Production) deploy-gate fix in hotfix-start ([b9f5569](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b9f5569787e06a908fcb19e81de30b111e0f8bd3)) +* **ci:** backport CD (Production) deploy-gate fix in hotfix-start ([bb740c2](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/bb740c221f15b5b64121cf95cfe3aba061b6fd8e)) +* **ci:** deploy on inline workflow_call to CD (Production) ([aa08f39](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/aa08f39567993c67075eb0b361e492ee027b2110)) +* **ci:** deploy on inline workflow_call to CD (Production) ([8371f64](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8371f646b08363a578d339d99da8ebf5863d21c9)) +* **ci:** deploy on inline workflow_call to CD (Production) ([474df42](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/474df42717a22ffc5e2f78999a60cd701cf82728)) +* **ci:** deploy on inline workflow_call to CD (Production) ([1a11ee9](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1a11ee9e5237d33ce7b9ee7f093dad5e023436c6)) +* **ci:** keep nightly pg_cron job production-only ([3bb19ba](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3bb19baaf9bfbb79eab8ea72d0ec00c067909e59)) +* **ci:** pass release tag to CD_production in manifest mode ([8b7830f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8b7830f800ed3fad233f7a06256cee308edd9e4d)) +* **ci:** pass release tag to CD_production in manifest mode ([6d29bb1](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/6d29bb1b1dec3db2667fe0d5630873cdb79695f8)) +* **ci:** resolve release tag from manifest so CD_production deploys ([0ace58e](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0ace58e9562fd9557cdc838e884cf658d4aeffa0)) +* **ci:** resolve release tag from manifest so CD_production deploys ([3153a76](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/3153a760d32b1ae1b42eb756745201c97eba776a)) +* **core/dependencies:** Corrected types & improved core route guard ([07cba37](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/07cba37fc578f26bf59999a9a31794a05d7ed925)) +* correct shapefile DBF schema and clean up temp dir on failure ([f63d9ce](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/f63d9ce2d28ba190b63628f40c61edee09ac1a5e)) +* **db:** dedupe NMW locations in measurement views, re-register pg_cron job ([12bfe8b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/12bfe8b0e16416e2068c9b5458930a17896af956)) +* **db:** dedupe NMW locations in measurement views, re-register pg_cron job ([d96f394](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d96f394c34355912368973b5fb9e10f4c95b00e5)) +* **db:** reparent a5b6c7d8e9f0 onto the EDR views revision ([eb89d04](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/eb89d046651b3517bf2299df635842d8747cfc20)) +* **deploy:** align hotfix migration head with production DB (unblock v1.1.x deploy) ([1ee8a4b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1ee8a4b893027b851450d6ed98c811aea283c365)) +* **deploy:** align hotfix migration head with production DB + fix release tag passthrough ([790377f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/790377f53e57c9f7671d45efbcab617189f2af36)) +* **deploy:** prevent App Engine request starvation under burst load ([fec7dbd](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fec7dbd2e0f59d6ce1c3319cda9fded5c6051eb8)) +* **deploy:** prevent App Engine request starvation under burst load ([385c974](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/385c9743185520b82034f0b1549d51c57781f9e9)) +* **deploy:** prevent App Engine request starvation under burst load ([b97ea83](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/b97ea83258aed9cf9d344e0e07e31bb657b684f1)) +* **deploy:** prevent App Engine request starvation under burst load ([0398dad](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/0398dad1ae63013166bf69e1c81c92ea0692f4e1)) +* **deploy:** propagate F4_1G instance class to staging + document OOM churn ([a830e71](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/a830e714171bb64d52929b6efbe0b685ac87e72c)) +* **deploy:** propagate F4_1G instance class to staging + OOM writeup ([8e79756](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8e79756a9de1b901455bb176130b86d6b2cd0208)) +* **deploy:** raise instance class to F4_1G to stop OOM instance churn ([ec012d6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ec012d60ff8b985e89c8a2ca4167b4f24cae8540)) +* drop child-row release filters from NGWMN construction/lithology views ([063c4a7](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/063c4a72c46b22056e58ee2cf597f2459588f51a)) +* drop child-row release filters from NGWMN construction/lithology views ([f63af23](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/f63af237371105f167bc4d7fb4694da50d108e37)) +* drop yield_per in get_thing_features (incompatible with unique) ([fa727c4](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/fa727c42898e5d8791f248ff4dc0b5f43c7b3da2)) +* export daily minimum depth for NGWMN transducer water levels ([8baea78](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/8baea780859ebd7b1f856adf5da165073ea7ed94)) +* export daily minimum depth for NGWMN transducer water levels ([50b0fae](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/50b0faeb462aad41bb024957c38058eb0928592c)) +* **group:** pass user into remove_thing_from_group for audit logging ([ebc9c50](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/ebc9c503e5a846300106f3a55bae9115f1432c14)) +* guard user dict access when auth dependency returns True ([1b0dccd](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/1b0dccdfce38f9a1d0a078ac015c0500619cc93f)) +* **monitoring:** use single region for OpenStatus free tier ([7802f01](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/7802f015134dcf22f436d3f7f6f937babb9caa0b)) +* **nmw:** FK-safe truncate + dedup locations in per-well OGC views ([f4c805f](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/f4c805f8a1c18aec694af3e8c9b07f46ffbf4227)) +* **openapi:** resolve schema generation failures ([89bb35b](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/89bb35b5200bd62ea38fa64aae08752805cfbc6d)) +* restrict NGWMN exports to public records across joined entities ([d7e7c5d](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d7e7c5d18a2b42e25c5dbf03b13b4ee79144971b)) +* stop per-request OOM on /geospatial export endpoint ([d3a1358](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/d3a1358f54b9b6d02a6187d0004ba44b2b365ed0)) +* stop per-request OOM on /geospatial export endpoint ([2ed12b6](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2ed12b602a080d98e2025fc0e4edefdc341412ba)) +* stream get_thing_features with yield_per instead of buffering ([877fff8](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/877fff833755034e9c0f1b67ed3d6c74a92d4084)) + + +### Reverts + +* **docker:** keep db image dev-only, drop pg_cron from it ([2526a83](https://github.com/DataIntegrationGroup/OcotilloAPI/commit/2526a8313b3b7b211237ced1397ceb5a902055ac)) + ## [1.2.0-rc](https://github.com/DataIntegrationGroup/OcotilloAPI/compare/v1.1.0...v1.2.0-rc) (2026-06-10)