From 0bc6f263fa4c32e8cf2b8497f84abe7503521480 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Wed, 15 Jul 2026 13:57:50 -0600 Subject: [PATCH 001/151] fix(tests): remove hardcoded group id assumption test_get_project_area assumed its "Test Group Foo" fixture would always get database id 1, which only held because nothing else in the suite created a Group row first. Any earlier-running test that inserts a Group (e.g. a data-migration test) shifts the sequence and breaks this test with an unrelated 404. Use the fixture's actual group.id instead of the literal 1. --- tests/test_geospatial.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_geospatial.py b/tests/test_geospatial.py index a25ce24cb..21608e80d 100644 --- a/tests/test_geospatial.py +++ b/tests/test_geospatial.py @@ -117,7 +117,7 @@ def populate(): session.add(group) session.commit() - yield + yield group # Cleanup session.delete(loc1) @@ -128,15 +128,15 @@ def populate(): session.commit() -def test_get_project_area(): - response = client.get("/geospatial/project-area/1") +def test_get_project_area(populate): + response = client.get(f"/geospatial/project-area/{populate.id}") assert response.status_code == 200 data = response.json() assert "type" in data assert data["type"] == "FeatureCollection" assert "features" in data assert len(data["features"]) > 0 - assert data["features"][0]["properties"]["group_id"] == 1 + assert data["features"][0]["properties"]["group_id"] == populate.id assert data["features"][0]["properties"]["group_name"] == "Test Group Foo" assert ( data["features"][0]["properties"]["group_description"] From 0bf9e8ce801cb65140eb415306643cb7e7c8ef90 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Wed, 15 Jul 2026 14:12:54 -0600 Subject: [PATCH 002/151] fix(build): package data_migrations with the app pyproject.toml's explicit setuptools packages list omitted data_migrations, so the installed `oco` CLI couldn't import it at all -- `oco data-migrations status/run` failed with ModuleNotFoundError for every migration, not just new ones. Running via `uv run pytest`/`python -c` masked this because pytest prepends the repo root to sys.path; the installed console-script entry point does not. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 11ff4d3f7..730f89e31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,7 @@ dependencies = [ package = true [tool.setuptools] -packages = ["alembic", "cli", "core", "db", "schemas", "services", "transfers"] +packages = ["alembic", "cli", "core", "data_migrations", "db", "schemas", "services", "transfers"] [project.scripts] oco = "cli.cli:cli" From ffdfe1843a430ea6d51580e2009ada4de5d0f5d6 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Wed, 15 Jul 2026 14:21:06 -0600 Subject: [PATCH 003/151] test(fixtures): default shared fixtures to public The upcoming ogc_* view filter excludes non-public records, and nine currently-passing tests in test_ogc.py assert that rows backed by the shared location/water_well_thing/group fixtures ARE present in ogc_* responses. Their "draft" default was an arbitrary safe value, not a deliberate test input -- confirmed by grepping the suite for anything that depends on it being specifically "draft". Flips the three fixtures plus one inline Group(...)construction in test_ogc.py that bypassed the fixture. --- tests/conftest.py | 6 +++--- tests/test_ogc.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9eb1afd15..b37962bec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -134,7 +134,7 @@ def location(): point="POINT(-107.949533 33.809665)", elevation=2464.9, county="Sierra", - release_status="draft", + release_status="public", state="NM", quad_name="Hillsboro Peak", ) @@ -182,7 +182,7 @@ def water_well_thing(location): name="Test Well", first_visit_date="2023-03-03", thing_type="water well", - release_status="draft", + release_status="public", well_depth=10, hole_depth=10, well_casing_diameter=5.0, @@ -1041,7 +1041,7 @@ def observation_to_delete(water_chemistry_sample, sensor): def group(water_well_thing): with session_ctx() as session: group = Group( - release_status="draft", + release_status="public", name="Test Group", description="This is a test group.", project_area="MULTIPOLYGON(((-107.2 33.6, -106.6 33.6, -106.6 34.2, -107.2 34.2, -107.2 33.6)))", diff --git a/tests/test_ogc.py b/tests/test_ogc.py index 42a0c9843..f711b9caa 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -413,7 +413,7 @@ def test_ogc_actively_monitored_wells_exposes_water_level_network_group_wells( group = Group( name="Water Level Network", group_type="Monitoring Plan", - release_status="draft", + release_status="public", ) session.add(group) session.flush() @@ -462,7 +462,7 @@ def test_ogc_actively_monitored_wells_excludes_latest_not_currently_monitored( group = Group( name="Water Level Network", group_type="Monitoring Plan", - release_status="draft", + release_status="public", ) session.add(group) session.flush() From 04e259cf25d9298fa8335965b76a907bba1ab074 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Wed, 15 Jul 2026 14:30:57 -0600 Subject: [PATCH 004/151] feat(ogc): filter ogc_* views to public records Every ogc_* view/materialized view selected release_status but never filtered on it, so the unauthenticated /ogcapi endpoints have been serving private and draft records alongside public ones (e.g. water_wells: 1,145 private + 140 draft rows next to 8,678 public). Adds "AND release_status = 'public'" to all 21 existing ogc_* relations and introduces ogc_locations (previously the locations collection pointed straight at the raw location table, which has no filter at all). ogc_actively_monitored_wells gets no predicate of its own -- it already inherits public-only rows transitively through its join to ogc_water_well_summary; adding a redundant filter on status_history.release_status would repeat a mistake already made and reverted once (see w1x2y3z4a5b6), since that column is never populated by the transfer scripts. Fully reversible: downgrade() rebuilds the same relations with the byte-identical unfiltered SQL that was in production before this migration (ogc_locations is the exception, since it didn't exist pre-migration -- downgrade drops it rather than recreating it unfiltered). Repoints core/pygeoapi-config.yml's locations provider at the new view in the same commit, since shipping the view without the config change (or vice versa) leaves the collection either unfiltered or broken. Layer visibility (which collections are published)is unchanged. That's separate, out-of-scope work. --- ...blic_release_status_filter_to_ogc_views.py | 1276 +++++++++++++++++ core/pygeoapi-config.yml | 2 +- 2 files changed, 1277 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py diff --git a/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py b/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py new file mode 100644 index 000000000..3cc644393 --- /dev/null +++ b/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py @@ -0,0 +1,1276 @@ +"""apply public release_status filter to ogc views + +Restricts every ogc_* view/materialized view to release_status = 'public' +so that published, unauthenticated OGC endpoints (/ogcapi) never expose +private or draft records. Reversible: downgrade() recreates the same 22 +relations with the byte-identical unfiltered SQL that was in production +before this migration, so "no predicate" is restored exactly rather than +approximated. + +ogc_actively_monitored_wells gets no predicate of its own -- it inherits +public-only rows transitively once ogc_water_well_summary is filtered (see +alembic/versions/w1x2y3z4a5b6_drop_child_release_filters_from_ngwmn_views.py +for why an extra child-table release_status filter here would be wrong). +Because it depends on ogc_water_well_summary via a direct JOIN, it must be +dropped before ogc_water_well_summary and recreated after. + +ogc_locations does not exist before this migration -- core/pygeoapi-config.yml +points the locations collection directly at the raw location table. This +migration creates ogc_locations for the first time (explicit column list, +no SELECT *, matching every other view in this file) and a separate change +repoints that one config line at it. Since ogc_locations never existed +unfiltered in production, downgrade() drops it rather than recreating an +unfiltered copy. + +Revision ID: f4a5b6c7d8e9 +Revises: y3z4a5b6c7d8 +Create Date: 2026-07-14 00:00:00.000000 +""" + +import re +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "f4a5b6c7d8e9" +down_revision: Union[str, Sequence[str], None] = "y3z4a5b6c7d8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +REQUIRED_TABLES = { + "thing", + "location", + "location_thing_association", + "group", + "group_thing_association", + "status_history", + "observation", + "sample", + "field_activity", + "field_event", + "data_provenance", + "NMA_MajorChemistry", + "NMA_Chemistry_SampleInfo", + "NMA_MinorTraceChemistry", +} + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + +# The 11 thing-type views still in scope after +# s4t5u6v7w8x9_drop_unused_well_type_ogc_views.py removed the well-subtype +# variants (abandoned_wells, artesian_wells, dry_holes, dug_wells, +# exploration_wells, injection_wells, monitoring_wells, observation_wells, +# piezometers, production_wells, test_wells). +THING_VIEWS = [ + ("water_wells", "water well"), + ("springs", "spring"), + ("diversions_surface_water", "diversion of surface water, etc."), + ("ephemeral_streams", "ephemeral stream"), + ("lakes_ponds_reservoirs", "lake, pond or reservoir"), + ("meteorological_stations", "meteorological station"), + ("other_things", "other"), + ("outfalls_wastewater_return_flow", "outfall of wastewater or return flow"), + ("perennial_streams", "perennial stream"), + ("rock_sample_locations", "rock sample location"), + ("soil_gas_sample_locations", "soil gas sample location"), +] + + +def _safe_view_id(view_id: str) -> str: + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", view_id): + raise ValueError(f"Unsafe view id: {view_id!r}") + return view_id + + +def _drop_view_or_materialized_view(view_name: str) -> None: + # DROP VIEW IF EXISTS / DROP MATERIALIZED VIEW IF EXISTS only suppress + # "relation does not exist" -- Postgres still raises WrongObjectType if + # the relation exists as the other kind (e.g. DROP VIEW against an + # existing materialized view), so the relation's actual kind must be + # checked first rather than trying both blindly. + bind = op.get_bind() + relkind = bind.execute( + text("SELECT relkind FROM pg_class WHERE oid = to_regclass(:name)"), + {"name": view_name}, + ).scalar() + if relkind == "m": + op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {view_name}")) + elif relkind == "v": + op.execute(text(f"DROP VIEW IF EXISTS {view_name}")) + + +def _check_required_tables() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + missing = REQUIRED_TABLES - existing_tables + if missing: + raise RuntimeError( + "Cannot apply public release_status filter to OGC views. " + f"Missing required tables: {', '.join(sorted(missing))}" + ) + + +def _create_thing_view(view_id: str, thing_type: str, public_only: bool) -> str: + safe_view_id = _safe_view_id(view_id) + escaped_thing_type = thing_type.replace("'", "''") + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE VIEW ogc_{safe_view_id} AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ) + SELECT + t.id, + t.name, + t.first_visit_date, + t.nma_pk_welldata, + t.well_depth, + t.hole_depth, + t.well_casing_diameter, + t.well_casing_depth, + t.well_completion_date, + t.well_driller_name, + t.well_construction_method, + t.well_pump_type, + t.well_pump_depth, + t.formation_completion_code, + t.nma_formation_zone, + t.release_status, + l.elevation, + l.point + FROM thing AS t + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE t.thing_type = '{escaped_thing_type}'{release_filter} + """ + + +def _create_latest_depth_view(public_only: bool) -> str: + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_latest_depth_to_water_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + o.value, + o.measuring_point_height, + -- Treat NULL measuring_point_height as 0 when computing + -- depth_to_water_bgs. + ( + o.value - COALESCE(o.measuring_point_height, 0) + ) AS depth_to_water_bgs, + ROW_NUMBER() OVER ( + PARTITION BY fe.thing_id + ORDER BY o.observation_datetime DESC, o.id DESC + ) AS rn + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL{release_filter} + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + ro.observation_id, + ro.observation_datetime, + ro.value AS depth_to_water_reference, + ro.measuring_point_height, + ro.depth_to_water_bgs, + l.point + FROM ranked_obs AS ro + JOIN thing AS t ON t.id = ro.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE ro.rn = 1 + """ + + +def _create_avg_tds_view(public_only: bool) -> str: + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_avg_tds_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + tds_obs AS ( + SELECT + csi.thing_id, + mc.id AS major_chemistry_id, + COALESCE(mc."AnalysisDate", csi."CollectionDate")::date AS observation_date, + mc."SampleValue" AS sample_value, + mc."Units" AS units + FROM "NMA_MajorChemistry" AS mc + JOIN "NMA_Chemistry_SampleInfo" AS csi + ON csi.id = mc.chemistry_sample_info_id + JOIN thing AS t ON t.id = csi.thing_id + WHERE + t.thing_type = 'water well' + AND mc."SampleValue" IS NOT NULL + AND ( + lower(coalesce(mc."Analyte", '')) IN ( + 'tds', + 'total dissolved solids' + ) + OR lower(coalesce(mc."Symbol", '')) = 'tds' + ){release_filter} + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + COUNT(to2.major_chemistry_id)::integer AS tds_observation_count, + AVG(to2.sample_value)::double precision AS avg_tds_value, + MIN(to2.observation_date) AS first_tds_observation_date, + MAX(to2.observation_date) AS last_tds_observation_date, + l.point + FROM tds_obs AS to2 + JOIN thing AS t ON t.id = to2.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + GROUP BY t.id, t.name, t.thing_type, l.point + """ + + +def _create_latest_tds_view(public_only: bool) -> str: + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE VIEW ogc_latest_tds_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + tds_obs AS ( + SELECT + csi.thing_id, + mc.id AS major_chemistry_id, + COALESCE(mc."AnalysisDate", csi."CollectionDate") AS observation_datetime, + mc."SampleValue" AS sample_value, + mc."Units" AS units + FROM "NMA_MajorChemistry" AS mc + JOIN "NMA_Chemistry_SampleInfo" AS csi + ON csi.id = mc.chemistry_sample_info_id + JOIN thing AS t ON t.id = csi.thing_id + WHERE + t.thing_type = 'water well' + AND mc."SampleValue" IS NOT NULL + AND ( + lower(coalesce(mc."Analyte", '')) IN ( + 'tds', + 'total dissolved solids' + ) + OR lower(coalesce(mc."Symbol", '')) = 'tds' + ){release_filter} + ), + ranked_tds AS ( + SELECT + to2.thing_id, + to2.major_chemistry_id, + to2.observation_datetime, + to2.sample_value, + to2.units, + ROW_NUMBER() OVER ( + PARTITION BY to2.thing_id + ORDER BY to2.observation_datetime DESC NULLS LAST, to2.major_chemistry_id DESC + ) AS rn + FROM tds_obs AS to2 + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + rt.major_chemistry_id, + rt.observation_datetime::date AS latest_tds_observation_date, + rt.sample_value AS latest_tds_value, + rt.units AS latest_tds_units, + l.point + FROM ranked_tds AS rt + JOIN thing AS t ON t.id = rt.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE rt.rn = 1 + """ + + +def _create_depth_to_water_trend_view(public_only: bool) -> str: + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_depth_to_water_trend_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + obs AS ( + SELECT + fe.thing_id, + o.observation_datetime, + (o.value - COALESCE(o.measuring_point_height, 0)) AS depth_to_water_bgs + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL{release_filter} + ), + agg AS ( + SELECT + ob.thing_id, + COUNT(*)::integer AS record_count, + MIN(ob.observation_datetime) AS first_observation_datetime, + MAX(ob.observation_datetime) AS last_observation_datetime, + EXTRACT(EPOCH FROM (MAX(ob.observation_datetime) - MIN(ob.observation_datetime))) + / 31557600.0 AS span_years, + REGR_SLOPE( + ob.depth_to_water_bgs, + EXTRACT(EPOCH FROM ob.observation_datetime) + ) * 31557600.0 AS slope_ft_per_year + FROM obs AS ob + GROUP BY ob.thing_id + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + a.record_count, + a.first_observation_datetime, + a.last_observation_datetime, + a.span_years, + a.slope_ft_per_year, + CASE + WHEN a.record_count >= 10 OR (a.record_count >= 4 AND a.span_years >= 2.0) THEN + CASE + WHEN a.slope_ft_per_year IS NULL THEN 'not enough data' + WHEN a.slope_ft_per_year > 0.25 THEN 'increasing' + WHEN a.slope_ft_per_year < -0.25 THEN 'decreasing' + ELSE 'stable' + END + ELSE 'not enough data' + END AS trend_category, + l.point + FROM agg AS a + JOIN thing AS t ON t.id = a.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + """ + + +def _create_water_well_summary_view(public_only: bool) -> str: + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_water_well_summary AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + wl_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + (o.value - COALESCE(o.measuring_point_height, 0)) AS water_level + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL{release_filter} + ), + wl_agg AS ( + SELECT + w.thing_id, + COUNT(*)::integer AS total_water_levels, + MIN(w.water_level) AS min_water_level, + MAX(w.water_level) AS max_water_level, + REGR_SLOPE( + w.water_level, + EXTRACT(EPOCH FROM w.observation_datetime) + ) * 31557600.0 AS water_level_trend_ft_per_year + FROM wl_obs AS w + GROUP BY w.thing_id + ), + wl_last AS ( + SELECT + ranked.thing_id, + ranked.water_level AS last_water_level, + ranked.observation_datetime AS last_water_level_datetime + FROM ( + SELECT + w.thing_id, + w.water_level, + w.observation_datetime, + ROW_NUMBER() OVER ( + PARTITION BY w.thing_id + ORDER BY w.observation_datetime DESC, w.observation_id DESC + ) AS rn + FROM wl_obs AS w + ) AS ranked + WHERE ranked.rn = 1 + ) + SELECT + t.id AS id, + t.name, + t.well_depth, + l.elevation, + dpl.collection_method AS elevation_method, + t.nma_formation_zone AS formation_zone, + wa.total_water_levels, + wl.last_water_level, + wl.last_water_level_datetime, + wa.min_water_level, + wa.max_water_level, + wa.water_level_trend_ft_per_year, + l.point + FROM thing AS t + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + JOIN wl_agg AS wa ON wa.thing_id = t.id + LEFT JOIN wl_last AS wl ON wl.thing_id = t.id + LEFT JOIN LATERAL ( + SELECT dp.collection_method + FROM data_provenance AS dp + WHERE + dp.target_table = 'location' + AND dp.target_id = l.id + AND dp.field_name = 'elevation' + ORDER BY dp.id DESC + LIMIT 1 + ) AS dpl ON true + WHERE t.thing_type = 'water well' + AND wa.total_water_levels > 0 + """ + + +# Static analyte columns for major chemistry pivots. +# Includes aliases observed in current DB values (e.g., Ca(total), IONBAL, TAn, TCat, Na+K). +STATIC_ANALYTE_COLUMNS_MAJOR: list[tuple[str, str]] = [ + ("tds", "tds"), + ("calcium", "calcium"), + ("calcium_total", "calcium_total"), + ("magnesium", "magnesium"), + ("magnesium_total", "magnesium_total"), + ("sodium", "sodium"), + ("sodium_total", "sodium_total"), + ("potassium", "potassium"), + ("potassium_total", "potassium_total"), + ("sodium_plus_potassium", "sodium_plus_potassium"), + ("bicarbonate", "bicarbonate"), + ("carbonate", "carbonate"), + ("sulfate", "sulfate"), + ("chloride", "chloride"), + ("ion_balance", "ion_balance"), + ("total_anions", "total_anions"), + ("total_cations", "total_cations"), + ("alkalinity", "alkalinity"), + ("hardness", "hardness"), + ("specific_conductance", "specific_conductance"), + ("ph", "ph"), + ("nitrate", "nitrate"), + ("fluoride", "fluoride"), + ("silica", "silica"), +] + + +def _major_chemistry_select_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.sample_value) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MAJOR + ] + ) + + +def _major_chemistry_unit_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.units) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MAJOR + ] + ) + + +def _create_major_chemistry_results_view(public_only: bool) -> str: + static_columns = _major_chemistry_select_columns() + static_unit_columns = _major_chemistry_unit_columns() + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_major_chemistry_results AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + chemistry_rows AS ( + SELECT + csi.thing_id, + mc.id AS result_id, + COALESCE(mc."AnalysisDate", csi."CollectionDate") AS observation_datetime, + trim(mc."Analyte") AS analyte_name, + trim(mc."Symbol") AS symbol_name, + mc."SampleValue"::double precision AS sample_value, + mc."Units" AS units + FROM "NMA_MajorChemistry" AS mc + JOIN "NMA_Chemistry_SampleInfo" AS csi + ON csi.id = mc.chemistry_sample_info_id + JOIN thing AS t + ON t.id = csi.thing_id + WHERE mc."SampleValue" IS NOT NULL + AND t.thing_type = 'water well'{release_filter} + ), + normalized_rows AS ( + SELECT + cr.thing_id, + cr.result_id, + cr.observation_datetime, + NULLIF( + regexp_replace( + lower(trim(coalesce(cr.analyte_name, ''))), + '[^a-z0-9]+', + '', + 'g' + ), + '' + ) AS analyte_token, + NULLIF( + regexp_replace( + lower(trim(coalesce(cr.symbol_name, ''))), + '[^a-z0-9]+', + '', + 'g' + ), + '' + ) AS symbol_token, + cr.sample_value, + cr.units + FROM chemistry_rows AS cr + ), + mapped_rows AS ( + SELECT + nr.thing_id, + nr.result_id, + nr.observation_datetime, + CASE + WHEN coalesce(nr.symbol_token, '') = 'tds' + OR coalesce(nr.analyte_token, '') IN ('tds', 'totaldissolvedsolids') + THEN 'tds' + + WHEN coalesce(nr.symbol_token, '') = 'ca' + OR coalesce(nr.analyte_token, '') = 'ca' + THEN 'calcium' + WHEN coalesce(nr.analyte_token, '') = 'catotal' + THEN 'calcium_total' + + WHEN coalesce(nr.symbol_token, '') = 'mg' + OR coalesce(nr.analyte_token, '') = 'mg' + THEN 'magnesium' + WHEN coalesce(nr.analyte_token, '') = 'mgtotal' + THEN 'magnesium_total' + + WHEN coalesce(nr.symbol_token, '') = 'na' + OR coalesce(nr.analyte_token, '') = 'na' + THEN 'sodium' + WHEN coalesce(nr.analyte_token, '') = 'natotal' + THEN 'sodium_total' + + WHEN coalesce(nr.symbol_token, '') = 'k' + OR coalesce(nr.analyte_token, '') = 'k' + THEN 'potassium' + WHEN coalesce(nr.analyte_token, '') = 'ktotal' + THEN 'potassium_total' + + WHEN coalesce(nr.analyte_token, '') = 'nak' + THEN 'sodium_plus_potassium' + + WHEN coalesce(nr.symbol_token, '') = 'hco3' + OR coalesce(nr.analyte_token, '') = 'hco3' + THEN 'bicarbonate' + WHEN coalesce(nr.symbol_token, '') = 'co3' + OR coalesce(nr.analyte_token, '') = 'co3' + THEN 'carbonate' + WHEN coalesce(nr.symbol_token, '') = 'so4' + OR coalesce(nr.analyte_token, '') = 'so4' + THEN 'sulfate' + WHEN coalesce(nr.symbol_token, '') = 'cl' + OR coalesce(nr.analyte_token, '') = 'cl' + THEN 'chloride' + + WHEN coalesce(nr.analyte_token, '') = 'ionbal' + THEN 'ion_balance' + WHEN coalesce(nr.analyte_token, '') = 'tan' + THEN 'total_anions' + WHEN coalesce(nr.analyte_token, '') = 'tcat' + THEN 'total_cations' + + WHEN coalesce(nr.analyte_token, '') IN ('alk', 'alkalinity') + THEN 'alkalinity' + WHEN coalesce(nr.analyte_token, '') IN ('hrd', 'hardness') + THEN 'hardness' + WHEN coalesce(nr.analyte_token, '') IN ( + 'condlab', + 'specificconductance', + 'specificconductivity', + 'conductivity' + ) + THEN 'specific_conductance' + WHEN coalesce(nr.symbol_token, '') = 'ph' + OR coalesce(nr.analyte_token, '') IN ('ph', 'phl') + THEN 'ph' + + WHEN coalesce(nr.symbol_token, '') = 'no3' + OR coalesce(nr.analyte_token, '') IN ('no3', 'nitrate') + THEN 'nitrate' + WHEN coalesce(nr.symbol_token, '') = 'f' + OR coalesce(nr.analyte_token, '') IN ('f', 'fluoride') + THEN 'fluoride' + WHEN coalesce(nr.symbol_token, '') = 'sio2' + OR coalesce(nr.analyte_token, '') IN ('sio2', 'silica') + THEN 'silica' + + ELSE NULL + END AS analyte_key, + nr.sample_value, + nr.units + FROM normalized_rows AS nr + ), + latest_results AS ( + SELECT + mr.thing_id, + mr.analyte_key, + mr.sample_value, + mr.units, + mr.observation_datetime, + ROW_NUMBER() OVER ( + PARTITION BY mr.thing_id, mr.analyte_key + ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC + ) AS rn + FROM mapped_rows AS mr + WHERE mr.analyte_key IS NOT NULL + ) + SELECT + t.id AS id, + ll.location_id, + t.name, + t.thing_type, + COUNT(*)::integer AS analyte_count, + MAX(lr.observation_datetime::date) AS latest_chemistry_date, +{static_columns}, +{static_unit_columns}, + l.point + FROM latest_results AS lr + JOIN thing AS t ON t.id = lr.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lr.rn = 1 + GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point + """ + + +STATIC_ANALYTE_COLUMNS_MINOR: list[tuple[str, str]] = [ + ("h2r", "h2r"), + ("o18r", "o18r"), + ("c13r", "c13r"), + ("c14", "c14"), + ("c14_years", "c14_years"), + ("fluoride", "fluoride"), + ("barium", "barium"), + ("barium_total", "barium_total"), + ("copper", "copper"), + ("copper_total", "copper_total"), + ("zinc", "zinc"), + ("zinc_total", "zinc_total"), + ("molybdenum", "molybdenum"), + ("molybdenum_total", "molybdenum_total"), + ("silica", "silica"), + ("silicon", "silicon"), + ("silicon_total", "silicon_total"), + ("manganese", "manganese"), + ("manganese_total", "manganese_total"), + ("iron", "iron"), + ("iron_total", "iron_total"), + ("strontium", "strontium"), + ("strontium_total", "strontium_total"), + ("chromium", "chromium"), + ("chromium_total", "chromium_total"), + ("boron", "boron"), + ("boron_total", "boron_total"), + ("uranium", "uranium"), + ("uranium_total", "uranium_total"), + ("lithium", "lithium"), + ("lithium_total", "lithium_total"), + ("silver", "silver"), + ("silver_total", "silver_total"), + ("antimony", "antimony"), + ("antimony_total", "antimony_total"), + ("beryllium", "beryllium"), + ("beryllium_total", "beryllium_total"), + ("lead", "lead"), + ("lead_total", "lead_total"), + ("thallium", "thallium"), + ("thallium_total", "thallium_total"), + ("bromide", "bromide"), + ("selenium", "selenium"), + ("selenium_total", "selenium_total"), + ("vanadium", "vanadium"), + ("vanadium_total", "vanadium_total"), + ("aluminum", "aluminum"), + ("aluminum_total", "aluminum_total"), + ("arsenic", "arsenic"), + ("arsenic_total", "arsenic_total"), + ("nickel", "nickel"), + ("nickel_total", "nickel_total"), + ("cadmium", "cadmium"), + ("cadmium_total", "cadmium_total"), + ("cobalt", "cobalt"), + ("cobalt_total", "cobalt_total"), + ("phosphate", "phosphate"), + ("nitrite", "nitrite"), + ("nitrate", "nitrate"), + ("nitrate_as_n", "nitrate_as_n"), + ("thorium", "thorium"), + ("thorium_total", "thorium_total"), + ("tin", "tin"), + ("tin_total", "tin_total"), + ("mercury", "mercury"), + ("mercury_total", "mercury_total"), + ("titanium", "titanium"), + ("titanium_total", "titanium_total"), +] + + +def _minor_chemistry_value_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.sample_value) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MINOR + ] + ) + + +def _minor_chemistry_unit_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.units) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MINOR + ] + ) + + +def _create_minor_chemistry_wells_view(public_only: bool) -> str: + value_columns = _minor_chemistry_value_columns() + unit_columns = _minor_chemistry_unit_columns() + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_minor_chemistry_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + chemistry_rows AS ( + SELECT + csi.thing_id, + mtc.id AS result_id, + COALESCE(mtc.analysis_date::timestamp, csi."CollectionDate") AS observation_datetime, + trim(mtc.analyte) AS analyte_name, + mtc.sample_value::double precision AS sample_value, + mtc.units AS units + FROM "NMA_MinorTraceChemistry" AS mtc + JOIN "NMA_Chemistry_SampleInfo" AS csi + ON csi.id = mtc.chemistry_sample_info_id + JOIN thing AS t ON t.id = csi.thing_id + WHERE + mtc.sample_value IS NOT NULL + AND t.thing_type = 'water well'{release_filter} + ), + normalized_rows AS ( + SELECT + cr.thing_id, + cr.result_id, + cr.observation_datetime, + NULLIF( + regexp_replace( + lower(trim(coalesce(cr.analyte_name, ''))), + '[^a-z0-9]+', + '', + 'g' + ), + '' + ) AS analyte_token, + cr.sample_value, + cr.units + FROM chemistry_rows AS cr + ), + mapped_rows AS ( + SELECT + nr.thing_id, + nr.result_id, + nr.observation_datetime, + CASE + WHEN coalesce(nr.analyte_token, '') = 'h2r' THEN 'h2r' + WHEN coalesce(nr.analyte_token, '') = 'o18r' THEN 'o18r' + WHEN coalesce(nr.analyte_token, '') = 'c13r' THEN 'c13r' + WHEN coalesce(nr.analyte_token, '') = 'c14' THEN 'c14' + WHEN coalesce(nr.analyte_token, '') = 'c14years' THEN 'c14_years' + + WHEN coalesce(nr.analyte_token, '') = 'f' THEN 'fluoride' + WHEN coalesce(nr.analyte_token, '') = 'ba' THEN 'barium' + WHEN coalesce(nr.analyte_token, '') = 'batotal' THEN 'barium_total' + WHEN coalesce(nr.analyte_token, '') = 'cu' THEN 'copper' + WHEN coalesce(nr.analyte_token, '') = 'cutotal' THEN 'copper_total' + WHEN coalesce(nr.analyte_token, '') = 'zn' THEN 'zinc' + WHEN coalesce(nr.analyte_token, '') = 'zntotal' THEN 'zinc_total' + WHEN coalesce(nr.analyte_token, '') = 'mo' THEN 'molybdenum' + WHEN coalesce(nr.analyte_token, '') = 'mototal' THEN 'molybdenum_total' + WHEN coalesce(nr.analyte_token, '') = 'sio2' THEN 'silica' + WHEN coalesce(nr.analyte_token, '') = 'si' THEN 'silicon' + WHEN coalesce(nr.analyte_token, '') = 'sitotal' THEN 'silicon_total' + WHEN coalesce(nr.analyte_token, '') = 'mn' THEN 'manganese' + WHEN coalesce(nr.analyte_token, '') = 'mntotal' THEN 'manganese_total' + WHEN coalesce(nr.analyte_token, '') = 'fe' THEN 'iron' + WHEN coalesce(nr.analyte_token, '') = 'fetotal' THEN 'iron_total' + WHEN coalesce(nr.analyte_token, '') = 'sr' THEN 'strontium' + WHEN coalesce(nr.analyte_token, '') = 'srtotal' THEN 'strontium_total' + WHEN coalesce(nr.analyte_token, '') = 'cr' THEN 'chromium' + WHEN coalesce(nr.analyte_token, '') = 'crtotal' THEN 'chromium_total' + WHEN coalesce(nr.analyte_token, '') = 'b' THEN 'boron' + WHEN coalesce(nr.analyte_token, '') = 'btotal' THEN 'boron_total' + WHEN coalesce(nr.analyte_token, '') = 'u' THEN 'uranium' + WHEN coalesce(nr.analyte_token, '') = 'utotal' THEN 'uranium_total' + WHEN coalesce(nr.analyte_token, '') = 'li' THEN 'lithium' + WHEN coalesce(nr.analyte_token, '') = 'litotal' THEN 'lithium_total' + WHEN coalesce(nr.analyte_token, '') = 'ag' THEN 'silver' + WHEN coalesce(nr.analyte_token, '') = 'agtotal' THEN 'silver_total' + WHEN coalesce(nr.analyte_token, '') = 'sb' THEN 'antimony' + WHEN coalesce(nr.analyte_token, '') = 'sbtotal' THEN 'antimony_total' + WHEN coalesce(nr.analyte_token, '') = 'be' THEN 'beryllium' + WHEN coalesce(nr.analyte_token, '') = 'betotal' THEN 'beryllium_total' + WHEN coalesce(nr.analyte_token, '') = 'pb' THEN 'lead' + WHEN coalesce(nr.analyte_token, '') = 'pbtotal' THEN 'lead_total' + WHEN coalesce(nr.analyte_token, '') = 'tl' THEN 'thallium' + WHEN coalesce(nr.analyte_token, '') = 'tltotal' THEN 'thallium_total' + WHEN coalesce(nr.analyte_token, '') = 'br' THEN 'bromide' + WHEN coalesce(nr.analyte_token, '') = 'se' THEN 'selenium' + WHEN coalesce(nr.analyte_token, '') = 'setotal' THEN 'selenium_total' + WHEN coalesce(nr.analyte_token, '') = 'v' THEN 'vanadium' + WHEN coalesce(nr.analyte_token, '') = 'vtotal' THEN 'vanadium_total' + WHEN coalesce(nr.analyte_token, '') = 'al' THEN 'aluminum' + WHEN coalesce(nr.analyte_token, '') = 'altotal' THEN 'aluminum_total' + WHEN coalesce(nr.analyte_token, '') = 'as' THEN 'arsenic' + WHEN coalesce(nr.analyte_token, '') = 'astotal' THEN 'arsenic_total' + WHEN coalesce(nr.analyte_token, '') = 'ni' THEN 'nickel' + WHEN coalesce(nr.analyte_token, '') = 'nitotal' THEN 'nickel_total' + WHEN coalesce(nr.analyte_token, '') = 'cd' THEN 'cadmium' + WHEN coalesce(nr.analyte_token, '') = 'cdtotal' THEN 'cadmium_total' + WHEN coalesce(nr.analyte_token, '') = 'co' THEN 'cobalt' + WHEN coalesce(nr.analyte_token, '') = 'cototal' THEN 'cobalt_total' + WHEN coalesce(nr.analyte_token, '') = 'po4' THEN 'phosphate' + WHEN coalesce(nr.analyte_token, '') = 'no2' THEN 'nitrite' + WHEN coalesce(nr.analyte_token, '') = 'no3' THEN 'nitrate' + WHEN coalesce(nr.analyte_token, '') = 'no3n' THEN 'nitrate_as_n' + WHEN coalesce(nr.analyte_token, '') = 'th' THEN 'thorium' + WHEN coalesce(nr.analyte_token, '') = 'thtotal' THEN 'thorium_total' + WHEN coalesce(nr.analyte_token, '') = 'sn' THEN 'tin' + WHEN coalesce(nr.analyte_token, '') = 'sntotal' THEN 'tin_total' + WHEN coalesce(nr.analyte_token, '') = 'hg' THEN 'mercury' + WHEN coalesce(nr.analyte_token, '') = 'hgtotal' THEN 'mercury_total' + WHEN coalesce(nr.analyte_token, '') = 'ti' THEN 'titanium' + WHEN coalesce(nr.analyte_token, '') = 'titotal' THEN 'titanium_total' + ELSE NULL + END AS analyte_key, + nr.sample_value, + nr.units + FROM normalized_rows AS nr + ), + latest_results AS ( + SELECT + mr.thing_id, + mr.analyte_key, + mr.sample_value, + mr.units, + mr.observation_datetime, + ROW_NUMBER() OVER ( + PARTITION BY mr.thing_id, mr.analyte_key + ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC + ) AS rn + FROM mapped_rows AS mr + WHERE mr.analyte_key IS NOT NULL + ) + SELECT + t.id AS id, + ll.location_id, + t.name, + t.thing_type, + COUNT(*)::integer AS analyte_count, + MAX(lr.observation_datetime::date) AS latest_chemistry_date, +{value_columns}, +{unit_columns}, + l.point + FROM latest_results AS lr + JOIN thing AS t ON t.id = lr.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lr.rn = 1 + AND t.thing_type = 'water well' + GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point + """ + + +METERS_TO_FEET = 3.28084 + + +def _create_water_elevation_view(public_only: bool) -> str: + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_water_elevation_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + CASE + WHEN lower(trim(o.unit)) IN ('m', 'meter', 'meters', 'metre', 'metres') THEN + (o.value * {METERS_TO_FEET}) - COALESCE(o.measuring_point_height, 0) + WHEN lower(trim(o.unit)) IN ('ft', 'foot', 'feet') THEN + o.value - COALESCE(o.measuring_point_height, 0) + ELSE + NULL + END AS depth_to_water_below_ground_surface + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL + AND lower(trim(o.unit)) IN ( + 'm', + 'meter', + 'meters', + 'metre', + 'metres', + 'ft', + 'foot', + 'feet' + ){release_filter} + ), + latest_obs AS ( + SELECT + ro.*, + ROW_NUMBER() OVER ( + PARTITION BY ro.thing_id + ORDER BY ro.observation_datetime DESC, ro.observation_id DESC + ) AS rn + FROM ranked_obs AS ro + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + lo.observation_id, + lo.observation_datetime, + l.elevation AS elevation_m, + lo.depth_to_water_below_ground_surface AS depth_to_water_below_ground_surface_ft, + ((l.elevation * {METERS_TO_FEET}) - lo.depth_to_water_below_ground_surface) + AS water_elevation_ft, + l.point + FROM latest_obs AS lo + JOIN thing AS t ON t.id = lo.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lo.rn = 1 + """ + + +def _create_actively_monitored_wells_view() -> str: + # No predicate of its own -- inherits public-only rows transitively via + # the JOIN to ogc_water_well_summary, which is itself filtered. Adding a + # filter on status_history.release_status here would repeat the mistake + # reverted in w1x2y3z4a5b6 (that column is never actually populated for + # this table). + return """ + CREATE VIEW ogc_actively_monitored_wells AS + WITH latest_monitoring_status AS ( + SELECT DISTINCT ON (sh.target_id) + sh.target_id AS thing_id, + sh.status_value + FROM status_history AS sh + WHERE + sh.target_table = 'thing' + AND sh.status_type = 'Monitoring Status' + ORDER BY sh.target_id, sh.start_date DESC, sh.id DESC + ) + SELECT + wws.id, + wws.name, + 'water well'::text AS thing_type, + wws.well_depth, + wws.elevation, + wws.elevation_method, + wws.formation_zone, + wws.total_water_levels, + wws.last_water_level, + wws.last_water_level_datetime, + wws.min_water_level, + wws.max_water_level, + wws.water_level_trend_ft_per_year, + g.id AS group_id, + g.name AS group_name, + g.group_type, + wws.point + FROM "group" AS g + JOIN group_thing_association AS gta ON gta.group_id = g.id + JOIN ogc_water_well_summary AS wws ON wws.id = gta.thing_id + JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id + WHERE lower(trim(g.name)) = 'water level network' + AND lms.status_value = 'Currently monitored' + """ + + +def _create_project_areas_view(public_only: bool) -> str: + release_filter = " AND g.release_status = 'public'" if public_only else "" + return f""" + CREATE VIEW ogc_project_areas AS + SELECT + g.id, + g.name, + g.description, + g.group_type, + g.release_status, + g.project_area + FROM "group" AS g + WHERE g.project_area IS NOT NULL{release_filter} + """ + + +def _create_locations_view() -> str: + # Explicit column list verified against db/location.py and its mixins + # (AutoBaseMixin/AuditMixin, ReleaseMixin, NotesMixin, DataProvenanceMixin). + # NotesMixin/DataProvenanceMixin add only polymorphic relationships, no + # real columns. Audit columns (created_at, created_by_*, updated_by_*) + # are deliberately excluded, matching every other view in this file -- + # none of them expose those columns either, even for Thing's otherwise + # thorough column list. + return """ + CREATE VIEW ogc_locations AS + SELECT + l.id, + l.nma_pk_location, + l.description, + l.county, + l.state, + l.quad_name, + l.nma_location_notes, + l.nma_coordinate_notes, + l.nma_data_reliability, + l.nma_date_created, + l.nma_site_date, + l.release_status, + l.elevation, + l.point + FROM location AS l + WHERE l.release_status = 'public' + """ + + +def _recreate_governed_views(public_only: bool) -> None: + # ogc_actively_monitored_wells depends on ogc_water_well_summary via a + # direct JOIN; Postgres refuses to drop a materialized view while a + # dependent view exists, so it must go first and come back last. + _drop_view_or_materialized_view("ogc_actively_monitored_wells") + + for view_id, thing_type in THING_VIEWS: + _drop_view_or_materialized_view(f"ogc_{_safe_view_id(view_id)}") + op.execute(text(_create_thing_view(view_id, thing_type, public_only))) + + _drop_view_or_materialized_view("ogc_latest_depth_to_water_wells") + op.execute(text(_create_latest_depth_view(public_only))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_latest_depth_to_water_wells IS " + "'Latest depth-to-water per well view for pygeoapi.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_latest_depth_to_water_wells_id " + "ON ogc_latest_depth_to_water_wells (id)" + ) + ) + + _drop_view_or_materialized_view("ogc_avg_tds_wells") + op.execute(text(_create_avg_tds_view(public_only))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_avg_tds_wells IS " + "'Average TDS per well from major chemistry results for pygeoapi.'" + ) + ) + op.execute( + text("CREATE UNIQUE INDEX ux_ogc_avg_tds_wells_id " "ON ogc_avg_tds_wells (id)") + ) + + _drop_view_or_materialized_view("ogc_latest_tds_wells") + op.execute(text(_create_latest_tds_view(public_only))) + op.execute( + text( + "COMMENT ON VIEW ogc_latest_tds_wells IS " + "'Latest TDS per well from major chemistry results for pygeoapi.'" + ) + ) + + _drop_view_or_materialized_view("ogc_depth_to_water_trend_wells") + op.execute(text(_create_depth_to_water_trend_view(public_only))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_depth_to_water_trend_wells IS " + "'Depth-to-water trend classification for water wells.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_depth_to_water_trend_wells_id " + "ON ogc_depth_to_water_trend_wells (id)" + ) + ) + + _drop_view_or_materialized_view("ogc_water_well_summary") + op.execute(text(_create_water_well_summary_view(public_only))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_water_well_summary IS " + "'Summary statistics for water wells including water-level trend.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_water_well_summary_id " + "ON ogc_water_well_summary (id)" + ) + ) + + _drop_view_or_materialized_view("ogc_major_chemistry_results") + op.execute(text(_create_major_chemistry_results_view(public_only))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_major_chemistry_results IS " + "'Latest major-chemistry analyte values per location, pivoted into static analyte columns.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_major_chemistry_results_id " + "ON ogc_major_chemistry_results (id)" + ) + ) + + _drop_view_or_materialized_view("ogc_minor_chemistry_wells") + op.execute(text(_create_minor_chemistry_wells_view(public_only))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_minor_chemistry_wells IS " + "'Latest minor/trace chemistry analyte values for water wells, pivoted into static analyte columns.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_minor_chemistry_wells_id " + "ON ogc_minor_chemistry_wells (id)" + ) + ) + + _drop_view_or_materialized_view("ogc_water_elevation_wells") + op.execute(text(_create_water_elevation_view(public_only))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_water_elevation_wells IS " + "'Latest water elevation per well with explicit units: " + "elevation_m, depth_to_water_below_ground_surface_ft, water_elevation_ft.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_water_elevation_wells_id " + "ON ogc_water_elevation_wells (id)" + ) + ) + + # Recreate now that ogc_water_well_summary exists again. + op.execute(text(_create_actively_monitored_wells_view())) + op.execute( + text( + "COMMENT ON VIEW ogc_actively_monitored_wells IS " + "'Wells in the Water Level Network group for pygeoapi.'" + ) + ) + + _drop_view_or_materialized_view("ogc_project_areas") + op.execute(text(_create_project_areas_view(public_only))) + op.execute( + text( + "COMMENT ON VIEW ogc_project_areas IS " + "'Project areas for groups with polygon boundaries for pygeoapi.'" + ) + ) + + +def upgrade() -> None: + _check_required_tables() + _recreate_governed_views(public_only=True) + + # ogc_locations does not exist before this migration -- see module + # docstring. Only ever created in its filtered form. + _drop_view_or_materialized_view("ogc_locations") + op.execute(text(_create_locations_view())) + op.execute( + text( + "COMMENT ON VIEW ogc_locations IS " + "'Public locations for pygeoapi, replacing the raw location table provider.'" + ) + ) + + +def downgrade() -> None: + _recreate_governed_views(public_only=False) + + # ogc_locations never existed unfiltered in production; downgrading + # drops it rather than recreating an unfiltered copy. + _drop_view_or_materialized_view("ogc_locations") diff --git a/core/pygeoapi-config.yml b/core/pygeoapi-config.yml index 45f3bac17..ccae84eab 100644 --- a/core/pygeoapi-config.yml +++ b/core/pygeoapi-config.yml @@ -54,7 +54,7 @@ resources: password: {postgres_password_env} search_path: [public] id_field: id - table: location + table: ogc_locations geom_field: point latest_depth_to_water_wells: From c91c8422be7813607da97183000f37811bddfaa4 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Wed, 15 Jul 2026 14:38:02 -0600 Subject: [PATCH 005/151] feat(data-migrations): publish existing project_areas ogc_project_areas is now filtered to public records, but all 56 current project_area-bearing group rows are release_status=draft (confirmed in docs/ogc-layer-audit.md), which would make the layer return zero rows until someone flips them. Uses the data_migrations framework (not Alembic, which is schema-only in this repo) so the change is tracked, skip-if-applied, and gated on this ticket's schema revision having landed first. Runs independently via `oco data-migrations run 20260714_0001_publish_project_areas` -- deliberately not swept in via run-all, which could also apply other unrelated pending migrations. No downgrade: this framework is forward-only, and a blanket revert couldn't tell rows this migration published apart from ones published independently afterward -- undoing it later means writing a new, deliberate migration instead. This is a genuine publication decision, not a mechanical schema change -- confirm the 56 rows are actually appropriate for public release before merging. --- .../20260714_0001_publish_project_areas.py | 44 +++++++++++++++++++ tests/test_data_migrations.py | 34 ++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 data_migrations/migrations/20260714_0001_publish_project_areas.py diff --git a/data_migrations/migrations/20260714_0001_publish_project_areas.py b/data_migrations/migrations/20260714_0001_publish_project_areas.py new file mode 100644 index 000000000..244231667 --- /dev/null +++ b/data_migrations/migrations/20260714_0001_publish_project_areas.py @@ -0,0 +1,44 @@ +# =============================================================================== +# Copyright 2026 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from sqlalchemy import update +from sqlalchemy.orm import Session + +from data_migrations.base import DataMigration +from db.group import Group + + +def run(session: Session) -> None: + session.execute( + update(Group) + .where(Group.project_area.isnot(None)) + .values(release_status="public") + ) + session.commit() + + +MIGRATION = DataMigration( + id="20260714_0001_publish_project_areas", + alembic_revision="f4a5b6c7d8e9", + name="Publish all project_areas records", + description=( + "Marks every group record backing the project_areas OGC layer " + "(project_area IS NOT NULL) as release_status='public'. Confirmed " + "via docs/ogc-layer-audit.md that all 56 current rows are " + "release_status='draft'." + ), + run=run, + is_repeatable=False, +) diff --git a/tests/test_data_migrations.py b/tests/test_data_migrations.py index 3b0ce5211..8c11177d0 100644 --- a/tests/test_data_migrations.py +++ b/tests/test_data_migrations.py @@ -20,8 +20,12 @@ move_notes = importlib.import_module( "data_migrations.migrations.20260205_0001_move_nma_location_notes" ) +publish_project_areas = importlib.import_module( + "data_migrations.migrations.20260714_0001_publish_project_areas" +) from db.location import Location from db.notes import Notes +from db.group import Group from db.engine import session_ctx @@ -105,3 +109,33 @@ def test_move_nma_location_notes_skips_duplicates(): session.delete(notes[0]) session.delete(location) session.commit() + + +def test_publish_project_areas_marks_project_area_groups_public(): + with session_ctx() as session: + draft_with_area = Group( + name="Draft Project Area A", + description="Has a project area, should be published.", + release_status="draft", + project_area="MULTIPOLYGON(((-107.2 33.6, -106.6 33.6, -106.6 34.2, -107.2 34.2, -107.2 33.6)))", + ) + draft_without_area = Group( + name="Draft No Area", + description="No project area, should be left alone.", + release_status="draft", + ) + session.add_all([draft_with_area, draft_without_area]) + session.commit() + session.refresh(draft_with_area) + session.refresh(draft_without_area) + + publish_project_areas.run(session) + + session.refresh(draft_with_area) + session.refresh(draft_without_area) + assert draft_with_area.release_status == "public" + assert draft_without_area.release_status == "draft" + + session.delete(draft_with_area) + session.delete(draft_without_area) + session.commit() From 716ca5284e6de57b360c230f4735a85fc0487ce9 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Wed, 15 Jul 2026 14:58:56 -0600 Subject: [PATCH 006/151] test(ogc): add behave coverage for public-only filter Covers the migration-application, downgrade-reversibility, already-consistent-layers, and project_areas scenarios in ogc-cleanup-sprint1.feature tagged @A1. The other ~10 tickets sharing that feature file have no steps yet and stay undefined, per plan. Tags @production only on these specific scenarios (the feature file has no feature-level tag since ~20 other tickets' scenarios share it) and @migration-mutates-schema on the three that call alembic downgrade, wiring a before/after_scenario hook in environment.py that unconditionally restores head so a downgrade left mid-scenario can't leak into later scenarios sharing this database. --- tests/features/environment.py | 11 +- tests/features/ogc-cleanup-sprint1.feature | 19 +- tests/features/steps/ogc-cleanup-sprint1.py | 672 ++++++++++++++++++++ 3 files changed, 695 insertions(+), 7 deletions(-) create mode 100644 tests/features/steps/ogc-cleanup-sprint1.py diff --git a/tests/features/environment.py b/tests/features/environment.py index 9cdff0d62..72e3a65e2 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -741,10 +741,19 @@ def after_all(context): def before_scenario(context, scenario): # runs before EVERY scenario # e.g. reset test data, open browser, etc. - pass + if "migration-mutates-schema" in scenario.tags: + # Defense in depth against a previous, unrelated failure having + # already left the database below head. + command.upgrade(_alembic_config(), "head") def after_scenario(context, scenario): + if "migration-mutates-schema" in scenario.tags: + # Runs whether the scenario passed or failed, so a downgrade left + # mid-scenario never leaks into later scenarios/features sharing + # this database. Deliberately not gated on DROP_AND_REBUILD_DB, + # since these scenarios mutate schema regardless of that flag. + command.upgrade(_alembic_config(), "head") if not get_bool_env("DROP_AND_REBUILD_DB"): return diff --git a/tests/features/ogc-cleanup-sprint1.feature b/tests/features/ogc-cleanup-sprint1.feature index 2049023bc..c510bb0fc 100644 --- a/tests/features/ogc-cleanup-sprint1.feature +++ b/tests/features/ogc-cleanup-sprint1.feature @@ -25,13 +25,13 @@ Feature: OGC Feature Layer Cleanup — Sprint 1 # A1 — Apply release_status = 'public' filter to all OGC views # --------------------------------------------------------------------------- - @backend @ogc-exposure @sprint-1 @high-priority @A1 + @backend @ogc-exposure @sprint-1 @high-priority @A1 @production @migration-mutates-schema @cleanup_samples Scenario: Sprint 1 migration restricts all ogc_* views to public records Given a clean database state before the Sprint 1 migration When the Sprint 1 Alembic migration is applied Then each ogc_* view returns only records with release_status "public" - @backend @ogc-exposure @sprint-1 @high-priority @A1 + @backend @ogc-exposure @sprint-1 @high-priority @A1 @production @migration-mutates-schema @cleanup_samples Scenario: Sprint 1 migration can be reversed without error Given the Sprint 1 migration has been applied When the Sprint 1 migration downgrade is run @@ -40,7 +40,7 @@ Feature: OGC Feature Layer Cleanup — Sprint 1 And each ogc_* view returns the same count of draft records as before the migration And no database errors are raised - @backend @ogc-exposure @sprint-1 @high-priority @A1 + @backend @ogc-exposure @sprint-1 @high-priority @A1 @production @cleanup_samples Scenario: Non-public records are excluded from every exposure-affected OGC layer Given the Sprint 1 migration has been applied When a public client requests items from each of the following layers: @@ -69,15 +69,22 @@ Feature: OGC Feature Layer Cleanup — Sprint 1 # other_things above: A1 must apply the filter to its view, but A18 removes # other_things from the catalog — run this scenario before A18 is applied - @backend @ogc-exposure @sprint-1 @high-priority @A1 + @backend @ogc-exposure @sprint-1 @high-priority @A1 @production Scenario: project_areas returns 56 rows after all records are updated to public Given all 56 project_areas records have been updated from release_status "draft" to release_status "public" When a client requests features from the project_areas layer Then the response contains 56 features And the response HTTP status is 200 And all returned features have release_status "public" - - @backend @ogc-exposure @sprint-1 @high-priority @A1 + # The "Given" precondition above is satisfied by a dedicated data + # migration (not the schema migration that adds the release_status + # filter) -- see the project_areas data migration in this ticket's + # implementation. Automated tests verify the underlying property + # (every project_area-bearing group ends up public), not the literal + # count 56, which is specific to today's real production data and is + # spot-checked manually against the target environment instead. + + @backend @ogc-exposure @sprint-1 @high-priority @A1 @production @migration-mutates-schema @cleanup_samples Scenario: The 4 already-consistent layers are unaffected by the migration Given the following layers were already filtering correctly before the migration: | layer-id | diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py new file mode 100644 index 000000000..b88da1130 --- /dev/null +++ b/tests/features/steps/ogc-cleanup-sprint1.py @@ -0,0 +1,672 @@ +# =============================================================================== +# Copyright 2026 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +"""Step definitions for A1 (public release_status filter on ogc_* views). + +Only the @A1-tagged scenarios in ogc-cleanup-sprint1.feature are implemented +here. The other ~10 tickets sharing that feature file have no steps yet and +stay undefined/dormant, per this ticket's plan. +""" +import importlib +from datetime import date + +from alembic import command +from behave import given, when, then +from sqlalchemy import text + +from core.dependencies import ( + viewer_function, + amp_viewer_function, + amp_editor_function, + admin_function, + amp_admin_function, +) +from starlette.testclient import TestClient + +from db import ( + Location, + Thing, + LocationThingAssociation, + Group, + GroupThingAssociation, + StatusHistory, + Contact, + FieldEvent, + FieldEventParticipant, + FieldActivity, + Sample, + Observation, + Sensor, + NMA_Chemistry_SampleInfo, + NMA_MajorChemistry, + NMA_MinorTraceChemistry, +) +from db.engine import session_ctx +from tests import get_parameter_id +from tests.features.environment import _alembic_config + +# Revision immediately before this ticket's schema migration -- re-verify +# with `alembic heads`/`alembic history` if this file is revisited later, +# since new migrations may have landed since. +PRE_A1_REVISION = "y3z4a5b6c7d8" + +# Maps every OGC layer-id used in this feature file's data tables to the +# seed group whose known public/private/draft ids should appear or not +# appear in that layer. The 9 derived/summary layers and +# actively_monitored_wells all key off the same seeded water wells. +LAYER_ID_TO_SEED_KEY = { + "water_wells": "water_wells", + "springs": "springs", + "perennial_streams": "perennial_streams", + "meteorological_stations": "meteorological_stations", + "diversions_surface_water": "diversions_surface_water", + "lakes_ponds_reservoirs": "lakes_ponds_reservoirs", + "other_things": "other_things", + "water_well_summary": "water_wells", + "depth_to_water_trend_wells": "water_wells", + "water_elevation_wells": "water_wells", + "major_chemistry_results": "water_wells", + "minor_chemistry_wells": "water_wells", + "latest_tds_wells": "water_wells", + "actively_monitored_wells": "water_wells", + "avg_tds_wells": "water_wells", + "latest_depth_to_water_wells": "water_wells", + "locations": "locations", + "project_areas": "project_areas", + "ephemeral_streams": "ephemeral_streams", + "rock_sample_locations": "rock_sample_locations", + "soil_gas_sample_locations": "soil_gas_sample_locations", + "outfalls_wastewater_return_flow": "outfalls_wastewater_return_flow", +} + +# Same mapping, keyed by the underlying ogc_* relation name, for the +# SQL-level scenarios (migration-application, downgrade-reversibility). +VIEW_TO_SEED_KEY = { + f"ogc_{layer_id}": seed_key for layer_id, seed_key in LAYER_ID_TO_SEED_KEY.items() +} + +# ogc_locations does not exist before A1 (core/pygeoapi-config.yml pointed +# directly at the raw location table), so downgrade drops it rather than +# recreating an unfiltered copy -- there is no "count before the migration" +# to compare it against once downgraded. +NOT_PRESENT_BEFORE_A1 = {"ogc_locations"} + +# Thing-type layers backed by the shared _create_thing_view template -- +# a simple Location + Thing pair is enough to exercise these. +SIMPLE_THING_TYPE_LAYERS = [ + ("springs", "spring"), + ("perennial_streams", "perennial stream"), + ("meteorological_stations", "meteorological station"), + ("diversions_surface_water", "diversion of surface water, etc."), + ("lakes_ponds_reservoirs", "lake, pond or reservoir"), + ("other_things", "other"), + ("ephemeral_streams", "ephemeral stream"), + ("rock_sample_locations", "rock sample location"), + ("soil_gas_sample_locations", "soil gas sample location"), + ("outfalls_wastewater_return_flow", "outfall of wastewater or return flow"), +] + +# The feature file's "4 already-consistent layers" scenario: today's live +# data for these thing types happens to be 100% public already. +ALREADY_CONSISTENT_LAYER_IDS = { + "ephemeral_streams", + "rock_sample_locations", + "soil_gas_sample_locations", + "outfalls_wastewater_return_flow", +} + +STATUSES = ("public", "private", "draft") + + +@given("the Ocotillo API is running") +def step_given_ocotillo_api_is_running(context): + from main import app + + def override_authentication(default=True): + def closure(): + return default + + return closure + + app.dependency_overrides[amp_admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[amp_editor_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[amp_viewer_function] = override_authentication() + app.dependency_overrides[viewer_function] = override_authentication() + + context.client = TestClient(app) + assert context.client is not None, "TestClient failed to initialize" + + +def _seed_thing_with_location(session, thing_type, release_status, name): + location = Location( + point="POINT(-106.5 34.0)", + elevation=1500.0, + release_status="public", + ) + session.add(location) + session.commit() + + thing = Thing( + name=name, + first_visit_date="2023-01-01", + thing_type=thing_type, + release_status=release_status, + ) + session.add(thing) + session.commit() + + assoc = LocationThingAssociation(location=location, thing=thing) + assoc.effective_start = "2023-01-01T00:00:00Z" + session.add(assoc) + session.commit() + session.refresh(thing) + return thing + + +def _seed_water_well(session, release_status, name, monitoring_group): + well = _seed_thing_with_location(session, "water well", release_status, name) + # well.id is used to keep every unique-constrained field below distinct + # across repeated _seed_all() calls within the same behave run (this + # helper runs once per A1 scenario, sharing one database). + uid = well.id + + # Observation chain feeding ogc_latest_depth_to_water_wells, + # ogc_depth_to_water_trend_wells, ogc_water_well_summary, + # ogc_water_elevation_wells. + contact = Contact( + name=f"A1 Contact {uid}", + role="Owner", + contact_type="Primary", + organization="NMBGMR", + release_status="draft", + ) + session.add(contact) + session.commit() + + field_event = FieldEvent( + thing_id=well.id, + event_date="2025-01-01T00:00:00Z", + notes="A1 behave seed field event", + release_status="draft", + ) + session.add(field_event) + session.commit() + + participant = FieldEventParticipant( + field_event_id=field_event.id, + contact_id=contact.id, + participant_role="Lead", + ) + session.add(participant) + session.commit() + + field_activity = FieldActivity( + field_event_id=field_event.id, + activity_type="groundwater level", + notes="A1 behave seed field activity", + release_status="draft", + ) + session.add(field_activity) + session.commit() + + sample = Sample( + field_activity_id=field_activity.id, + field_event_participant_id=participant.id, + sample_date="2025-01-01T12:00:00Z", + sample_name=f"A1 sample {uid}", + sample_matrix="water", + sample_method="Steel-tape measurement", + qc_type="Normal", + depth_top=None, + depth_bottom=None, + notes="A1 behave seed sample", + release_status="draft", + ) + session.add(sample) + session.commit() + + sensor = Sensor( + name=f"A1 Sensor {uid}", + sensor_type="Pressure Transducer", + model="Model X", + serial_no=f"A1-SN-{uid}", + pcn_number=f"A1-PCN-{uid}", + owner_agency="NMBGMR", + sensor_status="In Service", + notes="A1 behave seed sensor", + release_status="draft", + ) + session.add(sensor) + session.commit() + + observation = Observation( + observation_datetime="2025-01-01T00:04:00Z", + sample_id=sample.id, + sensor_id=sensor.id, + parameter_id=get_parameter_id("groundwater level", "Field Parameter"), + release_status="draft", + value=10.0, + unit="ft", + measuring_point_height=5.0, + groundwater_level_reason="Water level not affected", + ) + session.add(observation) + session.commit() + + # Chemistry rows feeding ogc_avg_tds_wells, ogc_latest_tds_wells, + # ogc_major_chemistry_results, ogc_minor_chemistry_wells. + # nma_sample_point_id is varchar(10) -- keep it short. + sample_point_id = f"A1{uid}"[:10] + csi = NMA_Chemistry_SampleInfo( + thing_id=well.id, + nma_sample_point_id=sample_point_id, + collection_date="2025-01-02T10:00:00Z", + ) + session.add(csi) + session.flush() + + major = NMA_MajorChemistry( + chemistry_sample_info_id=csi.id, + analyte="Total Dissolved Solids", + symbol="TDS", + sample_value=500.0, + units="mg/L", + analysis_date=None, + ) + session.add(major) + + minor = NMA_MinorTraceChemistry( + chemistry_sample_info_id=csi.id, + nma_sample_point_id=sample_point_id, + analyte="F", + symbol="", + sample_value=1.0, + units="mg/L", + analysis_date=date(2025, 1, 2), + ) + session.add(minor) + session.commit() + + # Water Level Network membership feeding ogc_actively_monitored_wells. + group_assoc = GroupThingAssociation(group_id=monitoring_group.id, thing_id=well.id) + session.add(group_assoc) + status_history = StatusHistory( + status_type="Monitoring Status", + status_value="Currently monitored", + start_date=date(2024, 1, 1), + end_date=None, + reason="A1 behave seed status", + target_id=well.id, + target_table="thing", + ) + session.add(status_history) + session.commit() + + return well + + +def _seed_all(session): + """Seed one public/private/draft row per relevant thing type, one + draft Group with a project_area, and one standalone Location per + status. Returns {seed_key: {"public": id, "private": id, "draft": id}}. + """ + seed_ids = {} + + monitoring_group = Group( + name="Water Level Network", + description="A1 behave seed monitoring group", + release_status="public", + ) + session.add(monitoring_group) + session.commit() + + for layer_id, thing_type in SIMPLE_THING_TYPE_LAYERS: + seed_ids[layer_id] = {} + for status in STATUSES: + thing = _seed_thing_with_location( + session, thing_type, status, f"A1 {layer_id} {status}" + ) + seed_ids[layer_id][status] = thing.id + + seed_ids["water_wells"] = {} + for status in STATUSES: + well = _seed_water_well( + session, status, f"A1 water well {status}", monitoring_group + ) + seed_ids["water_wells"][status] = well.id + + seed_ids["project_areas"] = {} + for status in STATUSES: + group = Group( + name=f"A1 project area {status}", + description="A1 behave seed project area group", + release_status=status, + project_area=( + "MULTIPOLYGON(((-107.2 33.6, -106.6 33.6, " + "-106.6 34.2, -107.2 34.2, -107.2 33.6)))" + ), + ) + session.add(group) + session.commit() + seed_ids["project_areas"][status] = group.id + + seed_ids["locations"] = {} + for status in STATUSES: + location = Location( + point="POINT(-106.0 34.5)", + elevation=1600.0, + release_status=status, + ) + session.add(location) + session.commit() + seed_ids["locations"][status] = location.id + + # Materialized views are snapshots, not live queries -- the newly + # seeded rows are invisible to them until refreshed. + session.execute(text("SELECT public.refresh_materialized_views()")) + session.commit() + + return seed_ids + + +def _teardown_a1_seed_data(): + """Delete every row these scenarios seed, by naming convention. + + Without this, Thing/Group rows from an earlier A1 scenario in the same + behave run leak into a later scenario's absolute feature-count checks + (e.g. the already-consistent-layers scenario). Registered via + context.add_cleanup so it runs after the scenario regardless of + pass/fail, without needing an environment.py hook. + """ + with session_ctx() as session: + session.execute(text("DELETE FROM thing WHERE name LIKE 'A1 %'")) + session.execute( + text( + "DELETE FROM \"group\" WHERE name LIKE 'A1 %' OR name = 'Water Level Network'" + ) + ) + session.commit() + + +def _ensure_head(context): + command.upgrade(_alembic_config(), "head") + with session_ctx() as session: + context.seed_ids = _seed_all(session) + context.add_cleanup(_teardown_a1_seed_data) + + +@given("a clean database state before the Sprint 1 migration") +def step_given_clean_database_state(context): + command.downgrade(_alembic_config(), PRE_A1_REVISION) + with session_ctx() as session: + context.seed_ids = _seed_all(session) + context.add_cleanup(_teardown_a1_seed_data) + + +@when("the Sprint 1 Alembic migration is applied") +def step_when_sprint1_alembic_migration_is_applied(context): + command.upgrade(_alembic_config(), "head") + + +@then('each ogc_* view returns only records with release_status "public"') +def step_then_views_are_public_only(context): + with session_ctx() as session: + for relation, seed_key in VIEW_TO_SEED_KEY.items(): + ids = context.seed_ids[seed_key] + for status in ("private", "draft"): + count = session.execute( + text(f"SELECT COUNT(*) FROM {relation} WHERE id = :id"), + {"id": ids[status]}, + ).scalar() + assert count == 0, ( + f"{relation} exposed a {status} row (id={ids[status]}) " + "that should have been filtered out" + ) + public_count = session.execute( + text(f"SELECT COUNT(*) FROM {relation} WHERE id = :id"), + {"id": ids["public"]}, + ).scalar() + assert public_count == 1, f"{relation} is missing its public seed row" + + +@given("the Sprint 1 migration has been applied") +def step_given_sprint1_migration_has_been_applied(context): + _ensure_head(context) + + +@when("the Sprint 1 migration downgrade is run") +def step_when_sprint1_migration_downgrade_is_run(context): + context.downgrade_error = None + try: + command.downgrade(_alembic_config(), PRE_A1_REVISION) + except Exception as exc: # noqa: BLE001 -- surfaced via the next Then step + context.downgrade_error = exc + + +@then( + "each ogc_* view returns the same count of {status} records as before the migration" +) +def step_then_same_count_as_before_migration(context, status): + assert ( + context.downgrade_error is None + ), f"Downgrade raised an error before counts could be checked: {context.downgrade_error}" + with session_ctx() as session: + for relation, seed_key in VIEW_TO_SEED_KEY.items(): + if relation in NOT_PRESENT_BEFORE_A1: + continue + ids = context.seed_ids[seed_key] + count = session.execute( + text(f"SELECT COUNT(*) FROM {relation} WHERE id = :id"), + {"id": ids[status]}, + ).scalar() + assert count == 1, ( + f"{relation}: expected the seeded {status} row to be visible again " + f"after downgrade (pre-A1 had no filter), got count={count}" + ) + + +@then("no database errors are raised") +def step_then_no_database_errors_are_raised(context): + assert ( + context.downgrade_error is None + ), f"Downgrade raised: {context.downgrade_error}" + # Restore head immediately so later scenarios/features never run against + # a downgraded schema even if a later step in this scenario fails. + command.upgrade(_alembic_config(), "head") + + +def _get_items(context, layer_id, limit=200): + response = context.client.get(f"/ogcapi/collections/{layer_id}/items?limit={limit}") + assert ( + response.status_code == 200 + ), f"Unexpected status {response.status_code} for layer {layer_id}: {response.text}" + return response.json() + + +@when("a public client requests items from each of the following layers:") +def step_when_public_client_requests_items_from_layers(context): + context.layer_responses = {} + for row in context.table: + layer_id = row["layer-id"].strip() + context.layer_responses[layer_id] = _get_items(context, layer_id) + + +def _layer_feature_ids(payload): + ids = set() + for feature in payload["features"]: + feature_id = feature.get("id", feature.get("properties", {}).get("id")) + ids.add(feature_id) + return ids + + +@then('each response contains only records where release_status is "public"') +def step_then_each_response_contains_only_public(context): + for layer_id, payload in context.layer_responses.items(): + seed_key = LAYER_ID_TO_SEED_KEY[layer_id] + features = payload["features"] + if features and "release_status" in features[0]["properties"]: + for feature in features: + assert ( + feature["properties"]["release_status"] == "public" + ), f"{layer_id} returned a non-public record: {feature['properties']}" + else: + ids_present = _layer_feature_ids(payload) + public_id = context.seed_ids[seed_key]["public"] + assert ( + public_id in ids_present + ), f"{layer_id} is missing its public seed row" + + +@then('no response contains a record where release_status is "{status}"') +def step_then_no_response_contains_status(context, status): + for layer_id, payload in context.layer_responses.items(): + seed_key = LAYER_ID_TO_SEED_KEY[layer_id] + features = payload["features"] + if features and "release_status" in features[0]["properties"]: + for feature in features: + assert ( + feature["properties"]["release_status"] != status + ), f"{layer_id} returned a {status} record: {feature['properties']}" + else: + ids_present = _layer_feature_ids(payload) + excluded_id = context.seed_ids[seed_key][status] + assert ( + excluded_id not in ids_present + ), f"{layer_id} exposed its seeded {status} row (id={excluded_id})" + + +@given( + 'all 56 project_areas records have been updated from release_status "draft" to release_status "public"' +) +def step_given_56_project_areas_updated_to_public(context): + publish_project_areas = importlib.import_module( + "data_migrations.migrations.20260714_0001_publish_project_areas" + ) + command.upgrade(_alembic_config(), "head") + with session_ctx() as session: + groups = [ + Group( + name=f"A1 56-count project area {i}", + description="A1 behave seed project area for the 56-row scenario", + release_status="draft", + project_area=( + "MULTIPOLYGON(((-107.0 33.0, -106.9 33.0, " + "-106.9 33.1, -107.0 33.1, -107.0 33.0)))" + ), + ) + for i in range(56) + ] + session.add_all(groups) + session.commit() + context.project_area_group_ids = [g.id for g in groups] + + publish_project_areas.run(session) + + +@when("a client requests features from the project_areas layer") +def step_when_client_requests_project_areas(context): + context.response = context.client.get( + "/ogcapi/collections/project_areas/items?limit=1000" + ) + + +@then("the response contains {count:d} features") +def step_then_response_contains_n_features(context, count): + payload = context.response.json() + matching = [ + f + for f in payload["features"] + if f.get("id", f.get("properties", {}).get("id")) + in set(context.project_area_group_ids) + ] + assert len(matching) == count, ( + f"Expected {count} of this scenario's seeded project_areas features, " + f"found {len(matching)}" + ) + + +@then("the response HTTP status is {status:d}") +def step_then_response_http_status_is(context, status): + assert ( + context.response.status_code == status + ), f"Unexpected status {context.response.status_code}, expected {status}" + + +@then('all returned features have release_status "public"') +def step_then_all_returned_features_are_public(context): + payload = context.response.json() + for feature in payload["features"]: + if feature.get("id", feature.get("properties", {}).get("id")) not in set( + context.project_area_group_ids + ): + continue + assert ( + feature["properties"]["release_status"] == "public" + ), f"Feature {feature.get('id')} was not public: {feature['properties']}" + + +def _seed_already_consistent_layers(session): + """These 4 layers are asserted to be 100% public today -- unlike + _seed_all(), only public rows are seeded here. Seeding private/draft + rows into them would defeat the point of this scenario: proving the + filter changes nothing because there was never anything to filter. + """ + for layer_id, thing_type in SIMPLE_THING_TYPE_LAYERS: + if layer_id not in ALREADY_CONSISTENT_LAYER_IDS: + continue + for i in range(3): + _seed_thing_with_location( + session, thing_type, "public", f"A1 already-consistent {layer_id} {i}" + ) + + +@given("the following layers were already filtering correctly before the migration:") +def step_given_already_consistent_layers(context): + command.downgrade(_alembic_config(), PRE_A1_REVISION) + with session_ctx() as session: + _seed_already_consistent_layers(session) + session.commit() + context.add_cleanup(_teardown_a1_seed_data) + + context.already_consistent_counts = {} + for row in context.table: + layer_id = row["layer-id"].strip() + payload = _get_items(context, layer_id, limit=500) + context.already_consistent_counts[layer_id] = len(payload["features"]) + + +@when("the Sprint 1 migration is applied") +def step_when_sprint1_migration_is_applied(context): + command.upgrade(_alembic_config(), "head") + + +@then("each of those layers returns the same feature count as before the migration") +def step_then_same_feature_count_as_before(context): + for layer_id, before_count in context.already_consistent_counts.items(): + payload = _get_items(context, layer_id, limit=500) + after_count = len(payload["features"]) + assert ( + after_count == before_count + ), f"{layer_id}: expected {before_count} features (unchanged), got {after_count}" + + +# ============= EOF ============================================= From 5169404cae06f3274441bbdaf7a9467c261209ae Mon Sep 17 00:00:00 2001 From: ksmuczynski <20096455+ksmuczynski@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:55:14 +0000 Subject: [PATCH 007/151] Formatting changes --- tests/features/steps/ogc-cleanup-sprint1.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py index b88da1130..5c2f3e5ee 100644 --- a/tests/features/steps/ogc-cleanup-sprint1.py +++ b/tests/features/steps/ogc-cleanup-sprint1.py @@ -19,6 +19,7 @@ here. The other ~10 tickets sharing that feature file have no steps yet and stay undefined/dormant, per this ticket's plan. """ + import importlib from datetime import date From 5bf50c8af8727e77b5d5b7fc88fdecaeabaac5a2 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 17 Jul 2026 23:15:42 -0600 Subject: [PATCH 008/151] feat(ogc): add auth gate for internal OGC mount pygeoapi is mounted via a raw Starlette Mount, so FastAPI's Depends() machinery never runs for it -- auth has to happen at the ASGI layer, in front of the mount, not as a route dependency. Added TokenInvalid and decode_token_payload() alongside the existing _get_token_payload so the same JWKS/jwt.decode logic can be reused from code with no FastAPI exception handler watching (raising HTTPException there would just be an unhandled exception, not a response). InternalOGCAuthMiddleware is a plain ASGI middleware class rather than BaseHTTPMiddleware (used elsewhere in this app for request logging and lazy admin init): BaseHTTPMiddleware buffers the full response body and breaks client-disconnect propagation, which matters here since this mount serves paginated GeoJSON up to max_items: 10000. Splits 401 (no/invalid token) from 403 (valid token, wrong group) per the acceptance criteria, rather than collapsing both into 401 like every other auth path in this app does today -- the underlying check is identical either way, so the split is a one-line branch, not meaningfully more code to maintain. --- core/dependencies.py | 6 ++ core/internal_ogc_auth.py | 115 ++++++++++++++++++++++++++++++++++++++ core/permissions.py | 32 +++++++++++ 3 files changed, 153 insertions(+) create mode 100644 core/internal_ogc_auth.py diff --git a/core/dependencies.py b/core/dependencies.py index eabcd009a..6372804a9 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -56,6 +56,12 @@ lexicon_editor_function = authenticated(permissions=["LexiconEditor"]) +# OGC-Internal Authentication/Permissions -------------------------------------- +# INTERNAL_OGC_GROUP ("OGCInternal") lives in core/permissions.py, not here -- +# it gates core/internal_ogc_auth.py's ASGI middleware in front of the +# /ogcapi-internal mount, which runs outside FastAPI's Depends() machinery. + + # Testing-Specific Authentication/Permissions ---------------------------------- no_permission_function = authenticated(permissions=["NoPermission"]) diff --git a/core/internal_ogc_auth.py b/core/internal_ogc_auth.py new file mode 100644 index 000000000..8edd767cf --- /dev/null +++ b/core/internal_ogc_auth.py @@ -0,0 +1,115 @@ +# =============================================================================== +# Copyright 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +"""ASGI auth gate for the authenticated internal OGC mount (/ogcapi-internal). + +pygeoapi is mounted via a raw Starlette Mount (core/pygeoapi.py), so FastAPI's +Depends() machinery never runs for it -- gating has to happen at the ASGI +layer, in front of the mount. This is a plain ASGI middleware class rather +than @app.middleware("http")/BaseHTTPMiddleware (used elsewhere in this +codebase): BaseHTTPMiddleware buffers the full response body and interferes +with client-disconnect propagation, which matters here since +/ogcapi-internal serves paginated GeoJSON up to `max_items: 10000`. On the +success path this calls straight through with zero buffering. + +Kept separate from core/permissions.py to avoid a circular import with +core/pygeoapi.py. +""" + +import json +import os + +from starlette.types import ASGIApp, Receive, Scope, Send + +from core import permissions +from core.settings import settings + + +def _extract_bearer_token(scope: Scope) -> str | None: + headers = dict(scope.get("headers") or []) + authorization = headers.get(b"authorization") + if not authorization: + return None + scheme, _, param = authorization.decode("latin-1").partition(" ") + if scheme.lower() != "bearer" or not param: + return None + return param + + +async def _send_json(send: Send, status_code: int, detail: str) -> None: + body = json.dumps({"detail": detail}).encode("utf-8") + await send( + { + "type": "http.response.start", + "status": status_code, + "headers": [(b"content-type", b"application/json")], + } + ) + await send({"type": "http.response.body", "body": body}) + + +class InternalOGCAuthMiddleware: + """Gates every request under `mount_path` behind INTERNAL_OGC_GROUP + membership; requests to any other path pass straight through untouched. + + Registered via app.add_middleware(), which wraps the whole app -- the + path check below is what keeps this scoped to the internal mount only. + """ + + def __init__(self, app: ASGIApp, mount_path: str) -> None: + self.app = app + self.mount_path = mount_path + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or not scope["path"].startswith(self.mount_path): + await self.app(scope, receive, send) + return + + if int(os.environ.get("AUTHENTIK_DISABLE_AUTHENTICATION", 0)): + if settings.mode == "production": + # HTTPException(424) (what core.permissions.authenticated() + # raises for this same misconfiguration) means nothing from + # raw ASGI code -- send the response directly so a + # misconfigured production box degrades to "internal mount + # always 424s" rather than crashing the worker. + await _send_json( + send, + 424, + "Authentication is disabled in production mode. Set " + "AUTHENTIK_DISABLE_AUTHENTICATION=0 to enable authentication.", + ) + return + await self.app(scope, receive, send) + return + + token = _extract_bearer_token(scope) + if not token: + await _send_json(send, 401, "Unauthorized") + return + + try: + payload = permissions.decode_token_payload(token) + except permissions.TokenInvalid: + await _send_json(send, 401, "Could not validate credentials") + return + + if permissions.INTERNAL_OGC_GROUP not in payload.get("groups", []): + await _send_json(send, 403, "Forbidden") + return + + await self.app(scope, receive, send) + + +# ============= EOF ============================================= diff --git a/core/permissions.py b/core/permissions.py index 952e844f4..fec27d37f 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -153,4 +153,36 @@ def _get_token_payload(token: str = Depends(oauth2_scheme)): ) +class TokenInvalid(Exception): + """Raised by decode_token_payload() for any JWT verification failure. + + Not an HTTPException: this is called from raw ASGI middleware + (core/internal_ogc_auth.py), which has no FastAPI exception handler + watching, so raising HTTPException there would just be an unhandled + exception rather than the intended response. + """ + + +# Required Authentik group for the authenticated internal OGC mount +# (/ogcapi-internal). Not Depends()-shaped like the roles above -- see the +# cross-reference note in core/dependencies.py for why it still lives here. +INTERNAL_OGC_GROUP = "OGCInternal" + + +def decode_token_payload(token: str) -> dict: + """Same JWT verification as _get_token_payload (get_public_key/JWKS/ + jwt.decode), but raises TokenInvalid instead of HTTPException(401). + """ + try: + public_key = get_public_key(token) + return jwt.decode( + token, + public_key, + algorithms=ALGORITHMS, + audience=os.environ.get("AUTHENTIK_CLIENT_ID"), + ) + except (JWTError, HTTPException) as e: + raise TokenInvalid(str(e)) from e + + # ============= EOF ============================================= From d2e874943d9941f26fb74c60ac341e5da077fcc8 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 17 Jul 2026 23:18:06 -0600 Subject: [PATCH 009/151] feat(ogc): add authenticated internal OGC mount Generalized _mount_path()/_pygeoapi_dir()/_write_config() to take an env var and default rather than hardcoding the public mount's values, so the internal mount gets the same traversal/character safety and sensitive-file handling for free instead of a copy-pasted second implementation. mount_pygeoapi_internal() gets its own guard flag and runtime dir so it can't silently no-op against the public mount's state, and a startup check that the two configured mount paths actually differ -- Starlette doesn't error on duplicate Mounts, it just routes to whichever registered first, which would leave the internal mount silently unreachable rather than failing loudly. Also added _assert_server_settings_match(): pygeoapi.api.API mutates process-wide globals (CHARSET, FORMAT_TYPES) during __init__, so whichever of the two mounts is built last wins for both. Inert today since both configs agree on encoding/gzip, but this fails startup loudly instead of letting a future divergence between the two configs silently corrupt responses on whichever mount lost the race. --- .env.example | 7 + core/factory.py | 3 +- core/pygeoapi-config-internal.yml | 293 ++++++++++++++++++++++++++++++ core/pygeoapi.py | 145 ++++++++++++--- 4 files changed, 425 insertions(+), 23 deletions(-) create mode 100644 core/pygeoapi-config-internal.yml diff --git a/.env.example b/.env.example index 3b53b9ae7..518028d1c 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,13 @@ POSTGRES_PORT=5432 PYGEOAPI_POSTGRES_PASSWORD=your_password PYGEOAPI_POSTGRES_USER=your_username +# PYGEOAPI internal mount (/ogcapi-internal) -- authenticated, unfiltered +# (private/draft-inclusive) mirror of /ogcapi. Shares PYGEOAPI_POSTGRES_* +# above; only the mount path, runtime dir, and advertised server URL differ. +PYGEOAPI_INTERNAL_MOUNT_PATH=/ogcapi-internal +PYGEOAPI_INTERNAL_RUNTIME_DIR=/tmp/pygeoapi-internal +PYGEOAPI_INTERNAL_SERVER_URL= + # Connection pool configuration for parallel transfers # pool_size: number of persistent connections to maintain # max_overflow: additional connections allowed during peak usage diff --git a/core/factory.py b/core/factory.py index 69bcfba7e..029b0c2de 100644 --- a/core/factory.py +++ b/core/factory.py @@ -44,9 +44,10 @@ def create_api_app(): initialize_runtime() app = create_base_app() register_api_routes(app) - from core.pygeoapi import mount_pygeoapi + from core.pygeoapi import mount_pygeoapi, mount_pygeoapi_internal mount_pygeoapi(app) + mount_pygeoapi_internal(app) if os.environ.get("SESSION_SECRET_KEY"): configure_session_middleware(app) configure_cors_middleware(app) diff --git a/core/pygeoapi-config-internal.yml b/core/pygeoapi-config-internal.yml new file mode 100644 index 000000000..7bfbb1590 --- /dev/null +++ b/core/pygeoapi-config-internal.yml @@ -0,0 +1,293 @@ +server: + bind: + host: 0.0.0.0 + port: 8000 + url: {server_url} + mimetype: application/json; charset=UTF-8 + encoding: utf-8 + language: en-US + limits: + default_items: 10 + max_items: 10000 + map: + url: https://tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png + attribution: "© OpenStreetMap contributors" + +logging: + level: INFO + +metadata: + identification: + title: Ocotillo OGC API (Internal) + description: >- + Authenticated internal OGC API - Features backed by PostGIS and + pygeoapi. Unlike the public /ogcapi mount, these collections are not + filtered by release_status and include private and draft records. + keywords: [features, ogcapi, postgis, pygeoapi, internal] + terms_of_service: https://example.com/terms + url: https://example.com + license: + name: CC-BY 4.0 + url: https://creativecommons.org/licenses/by/4.0/ + provider: + name: NMBGMR + url: https://geoinfo.nmt.edu + contact: + name: API Support + email: support@example.com + +resources: + locations: + type: collection + title: Locations + description: Geographic locations and site coordinates used by Ocotillo features. + keywords: [locations] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_internal_locations + geom_field: point + + latest_depth_to_water_wells: + type: collection + title: Latest Depth to Water (Water Wells) + description: Most recent depth-to-water below ground surface observation for each water well. + keywords: [water-wells, groundwater-level, depth-to-water-bgs, latest] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_internal_latest_depth_to_water_wells + geom_field: point + + avg_tds_wells: + type: collection + title: Average TDS (Water Wells) + description: Average total dissolved solids (TDS) from major chemistry results for each water well. + keywords: [water-wells, chemistry, tds, total-dissolved-solids, average] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_internal_avg_tds_wells + geom_field: point + + latest_tds_wells: + type: collection + title: Latest TDS (Water Wells) + description: Most recent total dissolved solids (TDS) result from major chemistry for each water well. + keywords: [water-wells, chemistry, tds, total-dissolved-solids, latest] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_internal_latest_tds_wells + geom_field: point + + depth_to_water_trend_wells: + type: collection + title: Depth to Water Trend (Water Wells) + description: Trend classification for depth to water based on slope in feet per year. + keywords: [water-wells, groundwater-level, depth-to-water, trend, slope] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_internal_depth_to_water_trend_wells + geom_field: point + + water_elevation_wells: + type: collection + title: Water Elevation (Water Wells) + description: Most recent water elevation per well calculated as elevation minus depth to water below ground surface. + keywords: [water-wells, groundwater-level, water-elevation, depth-to-water] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_internal_water_elevation_wells + geom_field: point + + water_well_summary: + type: collection + title: Water Well Summary + description: Summary metrics per water well, including latest, min/max, and trend for water levels. + keywords: [water-wells, summary, groundwater-level, trend] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_internal_water_well_summary + geom_field: point + + major_chemistry_results: + type: collection + title: Major Chemistry (Water Wells) + description: Latest major chemistry analyte values for water wells, represented as static analyte columns. + keywords: [water-wells, chemistry, analytes, major-chemistry] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_internal_major_chemistry_results + geom_field: point + + minor_chemistry_wells: + type: collection + title: Minor Chemistry (Water Wells) + description: Latest minor/trace chemistry analyte values for water wells, represented as static analyte columns. + keywords: [water-wells, chemistry, analytes, minor-chemistry, trace-chemistry] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_internal_minor_chemistry_wells + geom_field: point + + actively_monitored_wells: + type: collection + title: Actively Monitored Wells + description: Wells in the collaborative network currently flagged as actively monitored. + keywords: [water-wells, monitoring, collaborative-network, actively-monitored] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_internal_actively_monitored_wells + geom_field: point + + project_areas: + type: collection + title: Project Areas + description: Project groups with polygon project-area boundaries. + keywords: [project-areas, groups, boundaries] + extents: + spatial: + bbox: [-109.05, 31.33, -103.00, 37.00] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: PostgreSQL + data: + host: {postgres_host} + port: {postgres_port} + dbname: {postgres_db} + user: {postgres_user} + password: {postgres_password_env} + search_path: [public] + id_field: id + table: ogc_internal_project_areas + geom_field: project_area + +{thing_collections_block} diff --git a/core/pygeoapi.py b/core/pygeoapi.py index 7783af100..bc422d024 100644 --- a/core/pygeoapi.py +++ b/core/pygeoapi.py @@ -108,13 +108,17 @@ def _template_path() -> Path: return Path(__file__).resolve().parent / "pygeoapi-config.yml" -def _mount_path() -> str: - # Read and sanitize the configured mount path, defaulting to "/ogcapi". - path = (os.environ.get("PYGEOAPI_MOUNT_PATH", "/ogcapi") or "").strip() +def _internal_template_path() -> Path: + return Path(__file__).resolve().parent / "pygeoapi-config-internal.yml" + + +def _sanitized_mount_path(env_var: str, default: str) -> str: + # Read and sanitize the configured mount path, falling back to `default`. + path = (os.environ.get(env_var, default) or "").strip() # Treat empty or root ("/") values as invalid and fall back to the default. if path in {"", "/"}: - path = "/ogcapi" + path = default # Ensure a single leading slash. if not path.startswith("/"): @@ -127,21 +131,27 @@ def _mount_path() -> str: # Disallow traversal/current-directory segments. segments = [segment for segment in path.split("/") if segment] if any(segment in {".", ".."} for segment in segments): - raise ValueError( - "Invalid PYGEOAPI_MOUNT_PATH: traversal segments are not allowed." - ) + raise ValueError(f"Invalid {env_var}: traversal segments are not allowed.") # Allow only slash-delimited segments of alphanumerics, underscore, # or hyphen. if not re.fullmatch(r"/[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*", path): raise ValueError( - "Invalid PYGEOAPI_MOUNT_PATH: only letters, numbers, underscores, " + f"Invalid {env_var}: only letters, numbers, underscores, " "hyphens, and slashes are allowed." ) return path +def _mount_path() -> str: + return _sanitized_mount_path("PYGEOAPI_MOUNT_PATH", "/ogcapi") + + +def _internal_mount_path() -> str: + return _sanitized_mount_path("PYGEOAPI_INTERNAL_MOUNT_PATH", "/ogcapi-internal") + + def _server_url() -> str: configured = os.environ.get("PYGEOAPI_SERVER_URL") if configured: @@ -149,10 +159,19 @@ def _server_url() -> str: return f"http://localhost:8000{_mount_path()}" -def _pygeoapi_dir() -> Path: +def _internal_server_url() -> str: + configured = os.environ.get("PYGEOAPI_INTERNAL_SERVER_URL") + if configured: + return configured.rstrip("/") + return f"http://localhost:8000{_internal_mount_path()}" + + +def _pygeoapi_dir( + runtime_dir_env: str = "PYGEOAPI_RUNTIME_DIR", default: str = "/tmp/pygeoapi" +) -> Path: # Use instance-local ephemeral storage by default (GAE-safe). - runtime_dir = (os.environ.get("PYGEOAPI_RUNTIME_DIR") or "").strip() - path = Path(runtime_dir) if runtime_dir else Path("/tmp/pygeoapi") + runtime_dir = (os.environ.get(runtime_dir_env) or "").strip() + path = Path(runtime_dir) if runtime_dir else Path(default) path.mkdir(parents=True, exist_ok=True) return path @@ -163,6 +182,7 @@ def _thing_collections_block( dbname: str, user: str, password_placeholder: str, + table_prefix: str = "ogc_", ) -> str: resources: dict[str, dict] = {} for collection in THING_COLLECTIONS: @@ -190,7 +210,7 @@ def _thing_collections_block( "search_path": ["public"], }, "id_field": "id", - "table": f"ogc_{collection['id']}", + "table": f"{table_prefix}{collection['id']}", "geom_field": "point", } ], @@ -237,11 +257,17 @@ def _pygeoapi_db_settings() -> tuple[str, str, str, str, str]: return host, port, dbname, user, "${PYGEOAPI_POSTGRES_PASSWORD}" -def _write_config(path: Path) -> None: +def _write_config( + path: Path, + *, + server_url: str, + table_prefix: str = "ogc_", + template_path: Path | None = None, +) -> None: host, port, dbname, user, password_placeholder = _pygeoapi_db_settings() - template = _template_path().read_text(encoding="utf-8") + template = (template_path or _template_path()).read_text(encoding="utf-8") config = template.format( - server_url=_server_url(), + server_url=server_url, postgres_host=host, postgres_port=port, postgres_db=dbname, @@ -253,14 +279,15 @@ def _write_config(path: Path) -> None: dbname=dbname, user=user, password_placeholder=password_placeholder, + table_prefix=table_prefix, ), ) - # NOTE: The generated runtime config file at - # `${PYGEOAPI_RUNTIME_DIR}/pygeoapi-config.yml` (default: - # `/tmp/pygeoapi/pygeoapi-config.yml`) contains database connection details - # (host, port, dbname, user). Although the password is expected to be - # provided via environment variables at runtime by pygeoapi, this file - # should still be treated as sensitive configuration: + # NOTE: The generated runtime config file (default: + # `/tmp/pygeoapi/pygeoapi-config.yml` or + # `/tmp/pygeoapi-internal/pygeoapi-config.yml`) contains database + # connection details (host, port, dbname, user). Although the password is + # expected to be provided via environment variables at runtime by + # pygeoapi, this file should still be treated as sensitive configuration: # * Do not commit it to version control. # * Do not expose it in logs, error messages, or diagnostics. # * Ensure filesystem permissions restrict access appropriately. @@ -268,6 +295,32 @@ def _write_config(path: Path) -> None: path.chmod(0o600) +def _assert_server_settings_match( + public_config_path: Path, internal_config_path: Path +) -> None: + # pygeoapi.api.API.__init__ mutates process-wide, module-level globals + # (CHARSET, FORMAT_TYPES) that persist across the importlib.reload this + # scheme relies on -- whichever mount is constructed last wins for both. + # Inert as long as both configs agree on these settings; fail loudly at + # startup rather than let a future divergence silently corrupt responses + # on whichever mount lost the race. + public_server = yaml.safe_load(public_config_path.read_text(encoding="utf-8")).get( + "server", {} + ) + internal_server = yaml.safe_load( + internal_config_path.read_text(encoding="utf-8") + ).get("server", {}) + for key in ("encoding", "gzip"): + if public_server.get(key) != internal_server.get(key): + raise RuntimeError( + "pygeoapi public/internal config drift detected: " + f"server.{key} differs ({public_server.get(key)!r} vs " + f"{internal_server.get(key)!r}). Both configs must agree " + "here since pygeoapi.api.API.__init__ mutates shared " + "process-wide globals from these settings." + ) + + def _generate_openapi(config_path: Path, openapi_path: Path) -> None: from pygeoapi.openapi import generate_openapi_document @@ -300,7 +353,7 @@ def mount_pygeoapi(app: FastAPI) -> None: pygeoapi_dir = _pygeoapi_dir() config_path = pygeoapi_dir / "pygeoapi-config.yml" openapi_path = pygeoapi_dir / "pygeoapi-openapi.yml" - _write_config(config_path) + _write_config(config_path, server_url=_server_url()) _generate_openapi(config_path, openapi_path) os.environ["PYGEOAPI_CONFIG"] = str(config_path) @@ -311,3 +364,51 @@ def mount_pygeoapi(app: FastAPI) -> None: app.mount(mount_path, pygeoapi_app) app.state.pygeoapi_mounted = True + + +def mount_pygeoapi_internal(app: FastAPI) -> None: + if getattr(app.state, "pygeoapi_internal_mounted", False): + return + if find_spec("pygeoapi") is None: + raise RuntimeError( + "pygeoapi is not installed. Rebuild/sync dependencies so " + "/ogcapi-internal can be mounted." + ) + + public_mount_path = _mount_path() + internal_mount_path = _internal_mount_path() + if internal_mount_path == public_mount_path: + # Starlette doesn't error on duplicate mount paths -- it registers + # both Mounts and matches whichever was registered first (the + # public mount), leaving the internal mount silently unreachable. + # Fail loudly at startup instead of that way blind. + raise RuntimeError( + "PYGEOAPI_MOUNT_PATH and PYGEOAPI_INTERNAL_MOUNT_PATH both " + f"resolve to {internal_mount_path!r}. They must be distinct." + ) + + internal_dir = _pygeoapi_dir( + "PYGEOAPI_INTERNAL_RUNTIME_DIR", "/tmp/pygeoapi-internal" + ) + config_path = internal_dir / "pygeoapi-config.yml" + openapi_path = internal_dir / "pygeoapi-openapi.yml" + _write_config( + config_path, + server_url=_internal_server_url(), + table_prefix="ogc_internal_", + template_path=_internal_template_path(), + ) + _generate_openapi(config_path, openapi_path) + _assert_server_settings_match(_pygeoapi_dir() / "pygeoapi-config.yml", config_path) + + os.environ["PYGEOAPI_CONFIG"] = str(config_path) + os.environ["PYGEOAPI_OPENAPI"] = str(openapi_path) + + pygeoapi_app = _load_pygeoapi_app() + + from core.internal_ogc_auth import InternalOGCAuthMiddleware + + app.add_middleware(InternalOGCAuthMiddleware, mount_path=internal_mount_path) + app.mount(internal_mount_path, pygeoapi_app) + + app.state.pygeoapi_internal_mounted = True From 9e589ef993ff8005b65d4c4a20506970b02805e1 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 17 Jul 2026 23:20:33 -0600 Subject: [PATCH 010/151] feat(ogc): add unfiltered internal OGC views Mirrors all 22 ogc_* relations f4a5b6c7d8e9 filters, as ogc_internal_* counterparts with no release_status predicate, so /ogcapi-internal can serve private/draft records to authenticated staff. Reuses f4a5b6c7d8e9's parametrized builder functions (public_only kept in the signature and always passed False) for structural parity rather than a differently-shaped rewrite, since that's what the drift-detection test in the next commit diffs against. This repo keeps migrations self-contained with no cross-migration imports, so the two chemistry views' analyte-mapping CASE blocks are duplicated here rather than shared via a helper module -- a shared module would mean a future edit to one ticket's migration silently changes what the other replays from scratch. Added a one-line cross-reference comment in both files pointing at each other and the parity test. ogc_internal_locations has no release_status predicate at all (unlike ogc_locations, which is always public-only even on its own downgrade path) since it never existed in any filtered form before this migration. downgrade() simply drops all 22 relations rather than restoring a prior state, since none of them existed before this migration. --- .../2d3c3a268652_create_internal_ogc_views.py | 1304 +++++++++++++++++ ...blic_release_status_filter_to_ogc_views.py | 6 + 2 files changed, 1310 insertions(+) create mode 100644 alembic/versions/2d3c3a268652_create_internal_ogc_views.py diff --git a/alembic/versions/2d3c3a268652_create_internal_ogc_views.py b/alembic/versions/2d3c3a268652_create_internal_ogc_views.py new file mode 100644 index 000000000..affda020d --- /dev/null +++ b/alembic/versions/2d3c3a268652_create_internal_ogc_views.py @@ -0,0 +1,1304 @@ +"""create internal ogc views + +Companion migration to f4a5b6c7d8e9 (public release_status filter on ogc_* +views): creates a second, unfiltered copy of the same 22 relations, named +ogc_internal_, backing the authenticated /ogcapi-internal mount +(core/pygeoapi.py::mount_pygeoapi_internal). Full parity with the public +set, per ticket A11 -- not a subset. + +The major/minor chemistry analyte-mapping CASE blocks and +STATIC_ANALYTE_COLUMNS lists below are intentionally character-for-character +identical (modulo view name) to their counterparts in f4a5b6c7d8e9 -- this +codebase keeps migrations self-contained with no cross-migration imports, so +the logic is duplicated here rather than shared. tests/test_migration_view_ +parity.py enforces the two stay in sync: if you fix an analyte mapping in +one file, apply the same fix to the other. + +ogc_internal_locations has no release_status predicate at all (unlike +ogc_locations, which is always public-only) -- the internal mount is +unfiltered by design, and ogc_internal_locations never existed before this +migration in any form. + +ogc_internal_actively_monitored_wells gets no predicate of its own -- like +its public counterpart, it inherits whichever rows ogc_internal_water_well_ +summary exposes (here, all of them) transitively via a direct JOIN. Because +of that JOIN, it must be dropped before ogc_internal_water_well_summary and +recreated after (same ordering constraint as the public side). + +All 22 relations here are newly created by this migration -- none of them +existed in any form beforehand -- so downgrade() simply drops them rather +than recreating a prior state. + +Revision ID: 2d3c3a268652 +Revises: f4a5b6c7d8e9 +Create Date: 2026-07-16 00:00:00.000000 +""" + +import re +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "2d3c3a268652" +down_revision: Union[str, Sequence[str], None] = "f4a5b6c7d8e9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +REQUIRED_TABLES = { + "thing", + "location", + "location_thing_association", + "group", + "group_thing_association", + "status_history", + "observation", + "sample", + "field_activity", + "field_event", + "data_provenance", + "NMA_MajorChemistry", + "NMA_Chemistry_SampleInfo", + "NMA_MinorTraceChemistry", +} + +LATEST_LOCATION_CTE = """ +SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start +FROM location_thing_association AS lta +WHERE lta.effective_end IS NULL +ORDER BY lta.thing_id, lta.effective_start DESC +""".strip() + +# Same 11 thing-type views as f4a5b6c7d8e9's THING_VIEWS. +THING_VIEWS = [ + ("water_wells", "water well"), + ("springs", "spring"), + ("diversions_surface_water", "diversion of surface water, etc."), + ("ephemeral_streams", "ephemeral stream"), + ("lakes_ponds_reservoirs", "lake, pond or reservoir"), + ("meteorological_stations", "meteorological station"), + ("other_things", "other"), + ("outfalls_wastewater_return_flow", "outfall of wastewater or return flow"), + ("perennial_streams", "perennial stream"), + ("rock_sample_locations", "rock sample location"), + ("soil_gas_sample_locations", "soil gas sample location"), +] + + +def _safe_view_id(view_id: str) -> str: + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", view_id): + raise ValueError(f"Unsafe view id: {view_id!r}") + return view_id + + +def _drop_view_or_materialized_view(view_name: str) -> None: + # DROP VIEW IF EXISTS / DROP MATERIALIZED VIEW IF EXISTS only suppress + # "relation does not exist" -- Postgres still raises WrongObjectType if + # the relation exists as the other kind (e.g. DROP VIEW against an + # existing materialized view), so the relation's actual kind must be + # checked first rather than trying both blindly. + bind = op.get_bind() + relkind = bind.execute( + text("SELECT relkind FROM pg_class WHERE oid = to_regclass(:name)"), + {"name": view_name}, + ).scalar() + if relkind == "m": + op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {view_name}")) + elif relkind == "v": + op.execute(text(f"DROP VIEW IF EXISTS {view_name}")) + + +def _check_required_tables() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names(schema="public")) + missing = REQUIRED_TABLES - existing_tables + if missing: + raise RuntimeError( + "Cannot create internal OGC views. " + f"Missing required tables: {', '.join(sorted(missing))}" + ) + + +def _create_thing_view(view_id: str, thing_type: str, public_only: bool) -> str: + safe_view_id = _safe_view_id(view_id) + escaped_thing_type = thing_type.replace("'", "''") + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE VIEW ogc_internal_{safe_view_id} AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ) + SELECT + t.id, + t.name, + t.first_visit_date, + t.nma_pk_welldata, + t.well_depth, + t.hole_depth, + t.well_casing_diameter, + t.well_casing_depth, + t.well_completion_date, + t.well_driller_name, + t.well_construction_method, + t.well_pump_type, + t.well_pump_depth, + t.formation_completion_code, + t.nma_formation_zone, + t.release_status, + l.elevation, + l.point + FROM thing AS t + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE t.thing_type = '{escaped_thing_type}'{release_filter} + """ + + +def _create_latest_depth_view(public_only: bool) -> str: + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_internal_latest_depth_to_water_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + o.value, + o.measuring_point_height, + -- Treat NULL measuring_point_height as 0 when computing + -- depth_to_water_bgs. + ( + o.value - COALESCE(o.measuring_point_height, 0) + ) AS depth_to_water_bgs, + ROW_NUMBER() OVER ( + PARTITION BY fe.thing_id + ORDER BY o.observation_datetime DESC, o.id DESC + ) AS rn + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL{release_filter} + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + ro.observation_id, + ro.observation_datetime, + ro.value AS depth_to_water_reference, + ro.measuring_point_height, + ro.depth_to_water_bgs, + l.point + FROM ranked_obs AS ro + JOIN thing AS t ON t.id = ro.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE ro.rn = 1 + """ + + +def _create_avg_tds_view(public_only: bool) -> str: + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_internal_avg_tds_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + tds_obs AS ( + SELECT + csi.thing_id, + mc.id AS major_chemistry_id, + COALESCE(mc."AnalysisDate", csi."CollectionDate")::date AS observation_date, + mc."SampleValue" AS sample_value, + mc."Units" AS units + FROM "NMA_MajorChemistry" AS mc + JOIN "NMA_Chemistry_SampleInfo" AS csi + ON csi.id = mc.chemistry_sample_info_id + JOIN thing AS t ON t.id = csi.thing_id + WHERE + t.thing_type = 'water well' + AND mc."SampleValue" IS NOT NULL + AND ( + lower(coalesce(mc."Analyte", '')) IN ( + 'tds', + 'total dissolved solids' + ) + OR lower(coalesce(mc."Symbol", '')) = 'tds' + ){release_filter} + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + COUNT(to2.major_chemistry_id)::integer AS tds_observation_count, + AVG(to2.sample_value)::double precision AS avg_tds_value, + MIN(to2.observation_date) AS first_tds_observation_date, + MAX(to2.observation_date) AS last_tds_observation_date, + l.point + FROM tds_obs AS to2 + JOIN thing AS t ON t.id = to2.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + GROUP BY t.id, t.name, t.thing_type, l.point + """ + + +def _create_latest_tds_view(public_only: bool) -> str: + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE VIEW ogc_internal_latest_tds_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + tds_obs AS ( + SELECT + csi.thing_id, + mc.id AS major_chemistry_id, + COALESCE(mc."AnalysisDate", csi."CollectionDate") AS observation_datetime, + mc."SampleValue" AS sample_value, + mc."Units" AS units + FROM "NMA_MajorChemistry" AS mc + JOIN "NMA_Chemistry_SampleInfo" AS csi + ON csi.id = mc.chemistry_sample_info_id + JOIN thing AS t ON t.id = csi.thing_id + WHERE + t.thing_type = 'water well' + AND mc."SampleValue" IS NOT NULL + AND ( + lower(coalesce(mc."Analyte", '')) IN ( + 'tds', + 'total dissolved solids' + ) + OR lower(coalesce(mc."Symbol", '')) = 'tds' + ){release_filter} + ), + ranked_tds AS ( + SELECT + to2.thing_id, + to2.major_chemistry_id, + to2.observation_datetime, + to2.sample_value, + to2.units, + ROW_NUMBER() OVER ( + PARTITION BY to2.thing_id + ORDER BY to2.observation_datetime DESC NULLS LAST, to2.major_chemistry_id DESC + ) AS rn + FROM tds_obs AS to2 + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + rt.major_chemistry_id, + rt.observation_datetime::date AS latest_tds_observation_date, + rt.sample_value AS latest_tds_value, + rt.units AS latest_tds_units, + l.point + FROM ranked_tds AS rt + JOIN thing AS t ON t.id = rt.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE rt.rn = 1 + """ + + +def _create_depth_to_water_trend_view(public_only: bool) -> str: + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_internal_depth_to_water_trend_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + obs AS ( + SELECT + fe.thing_id, + o.observation_datetime, + (o.value - COALESCE(o.measuring_point_height, 0)) AS depth_to_water_bgs + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL{release_filter} + ), + agg AS ( + SELECT + ob.thing_id, + COUNT(*)::integer AS record_count, + MIN(ob.observation_datetime) AS first_observation_datetime, + MAX(ob.observation_datetime) AS last_observation_datetime, + EXTRACT(EPOCH FROM (MAX(ob.observation_datetime) - MIN(ob.observation_datetime))) + / 31557600.0 AS span_years, + REGR_SLOPE( + ob.depth_to_water_bgs, + EXTRACT(EPOCH FROM ob.observation_datetime) + ) * 31557600.0 AS slope_ft_per_year + FROM obs AS ob + GROUP BY ob.thing_id + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + a.record_count, + a.first_observation_datetime, + a.last_observation_datetime, + a.span_years, + a.slope_ft_per_year, + CASE + WHEN a.record_count >= 10 OR (a.record_count >= 4 AND a.span_years >= 2.0) THEN + CASE + WHEN a.slope_ft_per_year IS NULL THEN 'not enough data' + WHEN a.slope_ft_per_year > 0.25 THEN 'increasing' + WHEN a.slope_ft_per_year < -0.25 THEN 'decreasing' + ELSE 'stable' + END + ELSE 'not enough data' + END AS trend_category, + l.point + FROM agg AS a + JOIN thing AS t ON t.id = a.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + """ + + +def _create_water_well_summary_view(public_only: bool) -> str: + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_internal_water_well_summary AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + wl_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + (o.value - COALESCE(o.measuring_point_height, 0)) AS water_level + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL{release_filter} + ), + wl_agg AS ( + SELECT + w.thing_id, + COUNT(*)::integer AS total_water_levels, + MIN(w.water_level) AS min_water_level, + MAX(w.water_level) AS max_water_level, + REGR_SLOPE( + w.water_level, + EXTRACT(EPOCH FROM w.observation_datetime) + ) * 31557600.0 AS water_level_trend_ft_per_year + FROM wl_obs AS w + GROUP BY w.thing_id + ), + wl_last AS ( + SELECT + ranked.thing_id, + ranked.water_level AS last_water_level, + ranked.observation_datetime AS last_water_level_datetime + FROM ( + SELECT + w.thing_id, + w.water_level, + w.observation_datetime, + ROW_NUMBER() OVER ( + PARTITION BY w.thing_id + ORDER BY w.observation_datetime DESC, w.observation_id DESC + ) AS rn + FROM wl_obs AS w + ) AS ranked + WHERE ranked.rn = 1 + ) + SELECT + t.id AS id, + t.name, + t.well_depth, + l.elevation, + dpl.collection_method AS elevation_method, + t.nma_formation_zone AS formation_zone, + wa.total_water_levels, + wl.last_water_level, + wl.last_water_level_datetime, + wa.min_water_level, + wa.max_water_level, + wa.water_level_trend_ft_per_year, + l.point + FROM thing AS t + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + JOIN wl_agg AS wa ON wa.thing_id = t.id + LEFT JOIN wl_last AS wl ON wl.thing_id = t.id + LEFT JOIN LATERAL ( + SELECT dp.collection_method + FROM data_provenance AS dp + WHERE + dp.target_table = 'location' + AND dp.target_id = l.id + AND dp.field_name = 'elevation' + ORDER BY dp.id DESC + LIMIT 1 + ) AS dpl ON true + WHERE t.thing_type = 'water well' + AND wa.total_water_levels > 0 + """ + + +# Static analyte columns for major chemistry pivots. +# Includes aliases observed in current DB values (e.g., Ca(total), IONBAL, TAn, TCat, Na+K). +# Kept character-for-character identical to f4a5b6c7d8e9's copy -- see +# tests/test_migration_view_parity.py. +STATIC_ANALYTE_COLUMNS_MAJOR: list[tuple[str, str]] = [ + ("tds", "tds"), + ("calcium", "calcium"), + ("calcium_total", "calcium_total"), + ("magnesium", "magnesium"), + ("magnesium_total", "magnesium_total"), + ("sodium", "sodium"), + ("sodium_total", "sodium_total"), + ("potassium", "potassium"), + ("potassium_total", "potassium_total"), + ("sodium_plus_potassium", "sodium_plus_potassium"), + ("bicarbonate", "bicarbonate"), + ("carbonate", "carbonate"), + ("sulfate", "sulfate"), + ("chloride", "chloride"), + ("ion_balance", "ion_balance"), + ("total_anions", "total_anions"), + ("total_cations", "total_cations"), + ("alkalinity", "alkalinity"), + ("hardness", "hardness"), + ("specific_conductance", "specific_conductance"), + ("ph", "ph"), + ("nitrate", "nitrate"), + ("fluoride", "fluoride"), + ("silica", "silica"), +] + + +def _major_chemistry_select_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.sample_value) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MAJOR + ] + ) + + +def _major_chemistry_unit_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.units) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MAJOR + ] + ) + + +def _create_major_chemistry_results_view(public_only: bool) -> str: + static_columns = _major_chemistry_select_columns() + static_unit_columns = _major_chemistry_unit_columns() + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_internal_major_chemistry_results AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + chemistry_rows AS ( + SELECT + csi.thing_id, + mc.id AS result_id, + COALESCE(mc."AnalysisDate", csi."CollectionDate") AS observation_datetime, + trim(mc."Analyte") AS analyte_name, + trim(mc."Symbol") AS symbol_name, + mc."SampleValue"::double precision AS sample_value, + mc."Units" AS units + FROM "NMA_MajorChemistry" AS mc + JOIN "NMA_Chemistry_SampleInfo" AS csi + ON csi.id = mc.chemistry_sample_info_id + JOIN thing AS t + ON t.id = csi.thing_id + WHERE mc."SampleValue" IS NOT NULL + AND t.thing_type = 'water well'{release_filter} + ), + normalized_rows AS ( + SELECT + cr.thing_id, + cr.result_id, + cr.observation_datetime, + NULLIF( + regexp_replace( + lower(trim(coalesce(cr.analyte_name, ''))), + '[^a-z0-9]+', + '', + 'g' + ), + '' + ) AS analyte_token, + NULLIF( + regexp_replace( + lower(trim(coalesce(cr.symbol_name, ''))), + '[^a-z0-9]+', + '', + 'g' + ), + '' + ) AS symbol_token, + cr.sample_value, + cr.units + FROM chemistry_rows AS cr + ), + mapped_rows AS ( + SELECT + nr.thing_id, + nr.result_id, + nr.observation_datetime, + CASE + WHEN coalesce(nr.symbol_token, '') = 'tds' + OR coalesce(nr.analyte_token, '') IN ('tds', 'totaldissolvedsolids') + THEN 'tds' + + WHEN coalesce(nr.symbol_token, '') = 'ca' + OR coalesce(nr.analyte_token, '') = 'ca' + THEN 'calcium' + WHEN coalesce(nr.analyte_token, '') = 'catotal' + THEN 'calcium_total' + + WHEN coalesce(nr.symbol_token, '') = 'mg' + OR coalesce(nr.analyte_token, '') = 'mg' + THEN 'magnesium' + WHEN coalesce(nr.analyte_token, '') = 'mgtotal' + THEN 'magnesium_total' + + WHEN coalesce(nr.symbol_token, '') = 'na' + OR coalesce(nr.analyte_token, '') = 'na' + THEN 'sodium' + WHEN coalesce(nr.analyte_token, '') = 'natotal' + THEN 'sodium_total' + + WHEN coalesce(nr.symbol_token, '') = 'k' + OR coalesce(nr.analyte_token, '') = 'k' + THEN 'potassium' + WHEN coalesce(nr.analyte_token, '') = 'ktotal' + THEN 'potassium_total' + + WHEN coalesce(nr.analyte_token, '') = 'nak' + THEN 'sodium_plus_potassium' + + WHEN coalesce(nr.symbol_token, '') = 'hco3' + OR coalesce(nr.analyte_token, '') = 'hco3' + THEN 'bicarbonate' + WHEN coalesce(nr.symbol_token, '') = 'co3' + OR coalesce(nr.analyte_token, '') = 'co3' + THEN 'carbonate' + WHEN coalesce(nr.symbol_token, '') = 'so4' + OR coalesce(nr.analyte_token, '') = 'so4' + THEN 'sulfate' + WHEN coalesce(nr.symbol_token, '') = 'cl' + OR coalesce(nr.analyte_token, '') = 'cl' + THEN 'chloride' + + WHEN coalesce(nr.analyte_token, '') = 'ionbal' + THEN 'ion_balance' + WHEN coalesce(nr.analyte_token, '') = 'tan' + THEN 'total_anions' + WHEN coalesce(nr.analyte_token, '') = 'tcat' + THEN 'total_cations' + + WHEN coalesce(nr.analyte_token, '') IN ('alk', 'alkalinity') + THEN 'alkalinity' + WHEN coalesce(nr.analyte_token, '') IN ('hrd', 'hardness') + THEN 'hardness' + WHEN coalesce(nr.analyte_token, '') IN ( + 'condlab', + 'specificconductance', + 'specificconductivity', + 'conductivity' + ) + THEN 'specific_conductance' + WHEN coalesce(nr.symbol_token, '') = 'ph' + OR coalesce(nr.analyte_token, '') IN ('ph', 'phl') + THEN 'ph' + + WHEN coalesce(nr.symbol_token, '') = 'no3' + OR coalesce(nr.analyte_token, '') IN ('no3', 'nitrate') + THEN 'nitrate' + WHEN coalesce(nr.symbol_token, '') = 'f' + OR coalesce(nr.analyte_token, '') IN ('f', 'fluoride') + THEN 'fluoride' + WHEN coalesce(nr.symbol_token, '') = 'sio2' + OR coalesce(nr.analyte_token, '') IN ('sio2', 'silica') + THEN 'silica' + + ELSE NULL + END AS analyte_key, + nr.sample_value, + nr.units + FROM normalized_rows AS nr + ), + latest_results AS ( + SELECT + mr.thing_id, + mr.analyte_key, + mr.sample_value, + mr.units, + mr.observation_datetime, + ROW_NUMBER() OVER ( + PARTITION BY mr.thing_id, mr.analyte_key + ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC + ) AS rn + FROM mapped_rows AS mr + WHERE mr.analyte_key IS NOT NULL + ) + SELECT + t.id AS id, + ll.location_id, + t.name, + t.thing_type, + COUNT(*)::integer AS analyte_count, + MAX(lr.observation_datetime::date) AS latest_chemistry_date, +{static_columns}, +{static_unit_columns}, + l.point + FROM latest_results AS lr + JOIN thing AS t ON t.id = lr.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lr.rn = 1 + GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point + """ + + +# Kept character-for-character identical to f4a5b6c7d8e9's copy -- see +# tests/test_migration_view_parity.py. +STATIC_ANALYTE_COLUMNS_MINOR: list[tuple[str, str]] = [ + ("h2r", "h2r"), + ("o18r", "o18r"), + ("c13r", "c13r"), + ("c14", "c14"), + ("c14_years", "c14_years"), + ("fluoride", "fluoride"), + ("barium", "barium"), + ("barium_total", "barium_total"), + ("copper", "copper"), + ("copper_total", "copper_total"), + ("zinc", "zinc"), + ("zinc_total", "zinc_total"), + ("molybdenum", "molybdenum"), + ("molybdenum_total", "molybdenum_total"), + ("silica", "silica"), + ("silicon", "silicon"), + ("silicon_total", "silicon_total"), + ("manganese", "manganese"), + ("manganese_total", "manganese_total"), + ("iron", "iron"), + ("iron_total", "iron_total"), + ("strontium", "strontium"), + ("strontium_total", "strontium_total"), + ("chromium", "chromium"), + ("chromium_total", "chromium_total"), + ("boron", "boron"), + ("boron_total", "boron_total"), + ("uranium", "uranium"), + ("uranium_total", "uranium_total"), + ("lithium", "lithium"), + ("lithium_total", "lithium_total"), + ("silver", "silver"), + ("silver_total", "silver_total"), + ("antimony", "antimony"), + ("antimony_total", "antimony_total"), + ("beryllium", "beryllium"), + ("beryllium_total", "beryllium_total"), + ("lead", "lead"), + ("lead_total", "lead_total"), + ("thallium", "thallium"), + ("thallium_total", "thallium_total"), + ("bromide", "bromide"), + ("selenium", "selenium"), + ("selenium_total", "selenium_total"), + ("vanadium", "vanadium"), + ("vanadium_total", "vanadium_total"), + ("aluminum", "aluminum"), + ("aluminum_total", "aluminum_total"), + ("arsenic", "arsenic"), + ("arsenic_total", "arsenic_total"), + ("nickel", "nickel"), + ("nickel_total", "nickel_total"), + ("cadmium", "cadmium"), + ("cadmium_total", "cadmium_total"), + ("cobalt", "cobalt"), + ("cobalt_total", "cobalt_total"), + ("phosphate", "phosphate"), + ("nitrite", "nitrite"), + ("nitrate", "nitrate"), + ("nitrate_as_n", "nitrate_as_n"), + ("thorium", "thorium"), + ("thorium_total", "thorium_total"), + ("tin", "tin"), + ("tin_total", "tin_total"), + ("mercury", "mercury"), + ("mercury_total", "mercury_total"), + ("titanium", "titanium"), + ("titanium_total", "titanium_total"), +] + + +def _minor_chemistry_value_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.sample_value) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MINOR + ] + ) + + +def _minor_chemistry_unit_columns() -> str: + return ",\n".join( + [ + ( + " MAX(lr.units) FILTER " + f"(WHERE lr.analyte_key = '{analyte_key}') AS {column_name}_units" + ) + for analyte_key, column_name in STATIC_ANALYTE_COLUMNS_MINOR + ] + ) + + +def _create_minor_chemistry_wells_view(public_only: bool) -> str: + value_columns = _minor_chemistry_value_columns() + unit_columns = _minor_chemistry_unit_columns() + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_internal_minor_chemistry_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + chemistry_rows AS ( + SELECT + csi.thing_id, + mtc.id AS result_id, + COALESCE(mtc.analysis_date::timestamp, csi."CollectionDate") AS observation_datetime, + trim(mtc.analyte) AS analyte_name, + mtc.sample_value::double precision AS sample_value, + mtc.units AS units + FROM "NMA_MinorTraceChemistry" AS mtc + JOIN "NMA_Chemistry_SampleInfo" AS csi + ON csi.id = mtc.chemistry_sample_info_id + JOIN thing AS t ON t.id = csi.thing_id + WHERE + mtc.sample_value IS NOT NULL + AND t.thing_type = 'water well'{release_filter} + ), + normalized_rows AS ( + SELECT + cr.thing_id, + cr.result_id, + cr.observation_datetime, + NULLIF( + regexp_replace( + lower(trim(coalesce(cr.analyte_name, ''))), + '[^a-z0-9]+', + '', + 'g' + ), + '' + ) AS analyte_token, + cr.sample_value, + cr.units + FROM chemistry_rows AS cr + ), + mapped_rows AS ( + SELECT + nr.thing_id, + nr.result_id, + nr.observation_datetime, + CASE + WHEN coalesce(nr.analyte_token, '') = 'h2r' THEN 'h2r' + WHEN coalesce(nr.analyte_token, '') = 'o18r' THEN 'o18r' + WHEN coalesce(nr.analyte_token, '') = 'c13r' THEN 'c13r' + WHEN coalesce(nr.analyte_token, '') = 'c14' THEN 'c14' + WHEN coalesce(nr.analyte_token, '') = 'c14years' THEN 'c14_years' + + WHEN coalesce(nr.analyte_token, '') = 'f' THEN 'fluoride' + WHEN coalesce(nr.analyte_token, '') = 'ba' THEN 'barium' + WHEN coalesce(nr.analyte_token, '') = 'batotal' THEN 'barium_total' + WHEN coalesce(nr.analyte_token, '') = 'cu' THEN 'copper' + WHEN coalesce(nr.analyte_token, '') = 'cutotal' THEN 'copper_total' + WHEN coalesce(nr.analyte_token, '') = 'zn' THEN 'zinc' + WHEN coalesce(nr.analyte_token, '') = 'zntotal' THEN 'zinc_total' + WHEN coalesce(nr.analyte_token, '') = 'mo' THEN 'molybdenum' + WHEN coalesce(nr.analyte_token, '') = 'mototal' THEN 'molybdenum_total' + WHEN coalesce(nr.analyte_token, '') = 'sio2' THEN 'silica' + WHEN coalesce(nr.analyte_token, '') = 'si' THEN 'silicon' + WHEN coalesce(nr.analyte_token, '') = 'sitotal' THEN 'silicon_total' + WHEN coalesce(nr.analyte_token, '') = 'mn' THEN 'manganese' + WHEN coalesce(nr.analyte_token, '') = 'mntotal' THEN 'manganese_total' + WHEN coalesce(nr.analyte_token, '') = 'fe' THEN 'iron' + WHEN coalesce(nr.analyte_token, '') = 'fetotal' THEN 'iron_total' + WHEN coalesce(nr.analyte_token, '') = 'sr' THEN 'strontium' + WHEN coalesce(nr.analyte_token, '') = 'srtotal' THEN 'strontium_total' + WHEN coalesce(nr.analyte_token, '') = 'cr' THEN 'chromium' + WHEN coalesce(nr.analyte_token, '') = 'crtotal' THEN 'chromium_total' + WHEN coalesce(nr.analyte_token, '') = 'b' THEN 'boron' + WHEN coalesce(nr.analyte_token, '') = 'btotal' THEN 'boron_total' + WHEN coalesce(nr.analyte_token, '') = 'u' THEN 'uranium' + WHEN coalesce(nr.analyte_token, '') = 'utotal' THEN 'uranium_total' + WHEN coalesce(nr.analyte_token, '') = 'li' THEN 'lithium' + WHEN coalesce(nr.analyte_token, '') = 'litotal' THEN 'lithium_total' + WHEN coalesce(nr.analyte_token, '') = 'ag' THEN 'silver' + WHEN coalesce(nr.analyte_token, '') = 'agtotal' THEN 'silver_total' + WHEN coalesce(nr.analyte_token, '') = 'sb' THEN 'antimony' + WHEN coalesce(nr.analyte_token, '') = 'sbtotal' THEN 'antimony_total' + WHEN coalesce(nr.analyte_token, '') = 'be' THEN 'beryllium' + WHEN coalesce(nr.analyte_token, '') = 'betotal' THEN 'beryllium_total' + WHEN coalesce(nr.analyte_token, '') = 'pb' THEN 'lead' + WHEN coalesce(nr.analyte_token, '') = 'pbtotal' THEN 'lead_total' + WHEN coalesce(nr.analyte_token, '') = 'tl' THEN 'thallium' + WHEN coalesce(nr.analyte_token, '') = 'tltotal' THEN 'thallium_total' + WHEN coalesce(nr.analyte_token, '') = 'br' THEN 'bromide' + WHEN coalesce(nr.analyte_token, '') = 'se' THEN 'selenium' + WHEN coalesce(nr.analyte_token, '') = 'setotal' THEN 'selenium_total' + WHEN coalesce(nr.analyte_token, '') = 'v' THEN 'vanadium' + WHEN coalesce(nr.analyte_token, '') = 'vtotal' THEN 'vanadium_total' + WHEN coalesce(nr.analyte_token, '') = 'al' THEN 'aluminum' + WHEN coalesce(nr.analyte_token, '') = 'altotal' THEN 'aluminum_total' + WHEN coalesce(nr.analyte_token, '') = 'as' THEN 'arsenic' + WHEN coalesce(nr.analyte_token, '') = 'astotal' THEN 'arsenic_total' + WHEN coalesce(nr.analyte_token, '') = 'ni' THEN 'nickel' + WHEN coalesce(nr.analyte_token, '') = 'nitotal' THEN 'nickel_total' + WHEN coalesce(nr.analyte_token, '') = 'cd' THEN 'cadmium' + WHEN coalesce(nr.analyte_token, '') = 'cdtotal' THEN 'cadmium_total' + WHEN coalesce(nr.analyte_token, '') = 'co' THEN 'cobalt' + WHEN coalesce(nr.analyte_token, '') = 'cototal' THEN 'cobalt_total' + WHEN coalesce(nr.analyte_token, '') = 'po4' THEN 'phosphate' + WHEN coalesce(nr.analyte_token, '') = 'no2' THEN 'nitrite' + WHEN coalesce(nr.analyte_token, '') = 'no3' THEN 'nitrate' + WHEN coalesce(nr.analyte_token, '') = 'no3n' THEN 'nitrate_as_n' + WHEN coalesce(nr.analyte_token, '') = 'th' THEN 'thorium' + WHEN coalesce(nr.analyte_token, '') = 'thtotal' THEN 'thorium_total' + WHEN coalesce(nr.analyte_token, '') = 'sn' THEN 'tin' + WHEN coalesce(nr.analyte_token, '') = 'sntotal' THEN 'tin_total' + WHEN coalesce(nr.analyte_token, '') = 'hg' THEN 'mercury' + WHEN coalesce(nr.analyte_token, '') = 'hgtotal' THEN 'mercury_total' + WHEN coalesce(nr.analyte_token, '') = 'ti' THEN 'titanium' + WHEN coalesce(nr.analyte_token, '') = 'titotal' THEN 'titanium_total' + ELSE NULL + END AS analyte_key, + nr.sample_value, + nr.units + FROM normalized_rows AS nr + ), + latest_results AS ( + SELECT + mr.thing_id, + mr.analyte_key, + mr.sample_value, + mr.units, + mr.observation_datetime, + ROW_NUMBER() OVER ( + PARTITION BY mr.thing_id, mr.analyte_key + ORDER BY mr.observation_datetime DESC NULLS LAST, mr.result_id DESC + ) AS rn + FROM mapped_rows AS mr + WHERE mr.analyte_key IS NOT NULL + ) + SELECT + t.id AS id, + ll.location_id, + t.name, + t.thing_type, + COUNT(*)::integer AS analyte_count, + MAX(lr.observation_datetime::date) AS latest_chemistry_date, +{value_columns}, +{unit_columns}, + l.point + FROM latest_results AS lr + JOIN thing AS t ON t.id = lr.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lr.rn = 1 + AND t.thing_type = 'water well' + GROUP BY t.id, ll.location_id, t.name, t.thing_type, l.point + """ + + +METERS_TO_FEET = 3.28084 + + +def _create_water_elevation_view(public_only: bool) -> str: + release_filter = " AND t.release_status = 'public'" if public_only else "" + return f""" + CREATE MATERIALIZED VIEW ogc_internal_water_elevation_wells AS + WITH latest_location AS ( +{LATEST_LOCATION_CTE} + ), + ranked_obs AS ( + SELECT + fe.thing_id, + o.id AS observation_id, + o.observation_datetime, + CASE + WHEN lower(trim(o.unit)) IN ('m', 'meter', 'meters', 'metre', 'metres') THEN + (o.value * {METERS_TO_FEET}) - COALESCE(o.measuring_point_height, 0) + WHEN lower(trim(o.unit)) IN ('ft', 'foot', 'feet') THEN + o.value - COALESCE(o.measuring_point_height, 0) + ELSE + NULL + END AS depth_to_water_below_ground_surface + FROM observation AS o + JOIN sample AS s ON s.id = o.sample_id + JOIN field_activity AS fa ON fa.id = s.field_activity_id + JOIN field_event AS fe ON fe.id = fa.field_event_id + JOIN thing AS t ON t.id = fe.thing_id + WHERE + t.thing_type = 'water well' + AND fa.activity_type = 'groundwater level' + AND o.value IS NOT NULL + AND o.observation_datetime IS NOT NULL + AND lower(trim(o.unit)) IN ( + 'm', + 'meter', + 'meters', + 'metre', + 'metres', + 'ft', + 'foot', + 'feet' + ){release_filter} + ), + latest_obs AS ( + SELECT + ro.*, + ROW_NUMBER() OVER ( + PARTITION BY ro.thing_id + ORDER BY ro.observation_datetime DESC, ro.observation_id DESC + ) AS rn + FROM ranked_obs AS ro + ) + SELECT + t.id AS id, + t.name, + t.thing_type, + lo.observation_id, + lo.observation_datetime, + l.elevation AS elevation_m, + lo.depth_to_water_below_ground_surface AS depth_to_water_below_ground_surface_ft, + ((l.elevation * {METERS_TO_FEET}) - lo.depth_to_water_below_ground_surface) + AS water_elevation_ft, + l.point + FROM latest_obs AS lo + JOIN thing AS t ON t.id = lo.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE lo.rn = 1 + """ + + +def _create_actively_monitored_wells_view() -> str: + # No predicate of its own -- inherits whatever rows + # ogc_internal_water_well_summary exposes (here, all of them) + # transitively via the JOIN below. Mirrors the public side's + # ogc_actively_monitored_wells, which likewise never filters on + # status_history.release_status directly (see + # w1x2y3z4a5b6_drop_child_release_filters_from_ngwmn_views.py for why). + return """ + CREATE VIEW ogc_internal_actively_monitored_wells AS + WITH latest_monitoring_status AS ( + SELECT DISTINCT ON (sh.target_id) + sh.target_id AS thing_id, + sh.status_value + FROM status_history AS sh + WHERE + sh.target_table = 'thing' + AND sh.status_type = 'Monitoring Status' + ORDER BY sh.target_id, sh.start_date DESC, sh.id DESC + ) + SELECT + wws.id, + wws.name, + 'water well'::text AS thing_type, + wws.well_depth, + wws.elevation, + wws.elevation_method, + wws.formation_zone, + wws.total_water_levels, + wws.last_water_level, + wws.last_water_level_datetime, + wws.min_water_level, + wws.max_water_level, + wws.water_level_trend_ft_per_year, + g.id AS group_id, + g.name AS group_name, + g.group_type, + wws.point + FROM "group" AS g + JOIN group_thing_association AS gta ON gta.group_id = g.id + JOIN ogc_internal_water_well_summary AS wws ON wws.id = gta.thing_id + JOIN latest_monitoring_status AS lms ON lms.thing_id = wws.id + WHERE lower(trim(g.name)) = 'water level network' + AND lms.status_value = 'Currently monitored' + """ + + +def _create_project_areas_view(public_only: bool) -> str: + release_filter = " AND g.release_status = 'public'" if public_only else "" + return f""" + CREATE VIEW ogc_internal_project_areas AS + SELECT + g.id, + g.name, + g.description, + g.group_type, + g.release_status, + g.project_area + FROM "group" AS g + WHERE g.project_area IS NOT NULL{release_filter} + """ + + +def _create_locations_view() -> str: + # Unlike ogc_locations (always public-only, even on the public side's + # downgrade path), ogc_internal_locations has no release_status + # predicate at all -- the internal mount is unfiltered by design, and + # this relation never existed in any form before this migration. + # Column list matches ogc_locations exactly; see + # f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py's + # _create_locations_view() for the db/location.py column verification. + return """ + CREATE VIEW ogc_internal_locations AS + SELECT + l.id, + l.nma_pk_location, + l.description, + l.county, + l.state, + l.quad_name, + l.nma_location_notes, + l.nma_coordinate_notes, + l.nma_data_reliability, + l.nma_date_created, + l.nma_site_date, + l.release_status, + l.elevation, + l.point + FROM location AS l + """ + + +def _recreate_all_internal_views() -> None: + # ogc_internal_actively_monitored_wells depends on + # ogc_internal_water_well_summary via a direct JOIN; Postgres refuses to + # drop a materialized view while a dependent view exists, so it must go + # first and come back last -- same ordering constraint as the public side. + _drop_view_or_materialized_view("ogc_internal_actively_monitored_wells") + + for view_id, thing_type in THING_VIEWS: + _drop_view_or_materialized_view(f"ogc_internal_{_safe_view_id(view_id)}") + op.execute(text(_create_thing_view(view_id, thing_type, public_only=False))) + + _drop_view_or_materialized_view("ogc_internal_latest_depth_to_water_wells") + op.execute(text(_create_latest_depth_view(public_only=False))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_internal_latest_depth_to_water_wells IS " + "'Unfiltered latest depth-to-water per well view for the internal pygeoapi mount.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_internal_latest_depth_to_water_wells_id " + "ON ogc_internal_latest_depth_to_water_wells (id)" + ) + ) + + _drop_view_or_materialized_view("ogc_internal_avg_tds_wells") + op.execute(text(_create_avg_tds_view(public_only=False))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_internal_avg_tds_wells IS " + "'Unfiltered average TDS per well from major chemistry results for the internal pygeoapi mount.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_internal_avg_tds_wells_id " + "ON ogc_internal_avg_tds_wells (id)" + ) + ) + + _drop_view_or_materialized_view("ogc_internal_latest_tds_wells") + op.execute(text(_create_latest_tds_view(public_only=False))) + op.execute( + text( + "COMMENT ON VIEW ogc_internal_latest_tds_wells IS " + "'Unfiltered latest TDS per well from major chemistry results for the internal pygeoapi mount.'" + ) + ) + + _drop_view_or_materialized_view("ogc_internal_depth_to_water_trend_wells") + op.execute(text(_create_depth_to_water_trend_view(public_only=False))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_internal_depth_to_water_trend_wells IS " + "'Unfiltered depth-to-water trend classification for water wells, for the internal pygeoapi mount.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_internal_depth_to_water_trend_wells_id " + "ON ogc_internal_depth_to_water_trend_wells (id)" + ) + ) + + _drop_view_or_materialized_view("ogc_internal_water_well_summary") + op.execute(text(_create_water_well_summary_view(public_only=False))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_internal_water_well_summary IS " + "'Unfiltered summary statistics for water wells including water-level trend, for the internal pygeoapi mount.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_internal_water_well_summary_id " + "ON ogc_internal_water_well_summary (id)" + ) + ) + + _drop_view_or_materialized_view("ogc_internal_major_chemistry_results") + op.execute(text(_create_major_chemistry_results_view(public_only=False))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_internal_major_chemistry_results IS " + "'Unfiltered latest major-chemistry analyte values per location, pivoted into static analyte columns, for the internal pygeoapi mount.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_internal_major_chemistry_results_id " + "ON ogc_internal_major_chemistry_results (id)" + ) + ) + + _drop_view_or_materialized_view("ogc_internal_minor_chemistry_wells") + op.execute(text(_create_minor_chemistry_wells_view(public_only=False))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_internal_minor_chemistry_wells IS " + "'Unfiltered latest minor/trace chemistry analyte values for water wells, pivoted into static analyte columns, for the internal pygeoapi mount.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_internal_minor_chemistry_wells_id " + "ON ogc_internal_minor_chemistry_wells (id)" + ) + ) + + _drop_view_or_materialized_view("ogc_internal_water_elevation_wells") + op.execute(text(_create_water_elevation_view(public_only=False))) + op.execute( + text( + "COMMENT ON MATERIALIZED VIEW ogc_internal_water_elevation_wells IS " + "'Unfiltered latest water elevation per well with explicit units: " + "elevation_m, depth_to_water_below_ground_surface_ft, water_elevation_ft, for the internal pygeoapi mount.'" + ) + ) + op.execute( + text( + "CREATE UNIQUE INDEX ux_ogc_internal_water_elevation_wells_id " + "ON ogc_internal_water_elevation_wells (id)" + ) + ) + + # Recreate now that ogc_internal_water_well_summary exists again. + op.execute(text(_create_actively_monitored_wells_view())) + op.execute( + text( + "COMMENT ON VIEW ogc_internal_actively_monitored_wells IS " + "'Unfiltered wells in the Water Level Network group, for the internal pygeoapi mount.'" + ) + ) + + _drop_view_or_materialized_view("ogc_internal_project_areas") + op.execute(text(_create_project_areas_view(public_only=False))) + op.execute( + text( + "COMMENT ON VIEW ogc_internal_project_areas IS " + "'Unfiltered project areas for groups with polygon boundaries, for the internal pygeoapi mount.'" + ) + ) + + _drop_view_or_materialized_view("ogc_internal_locations") + op.execute(text(_create_locations_view())) + op.execute( + text( + "COMMENT ON VIEW ogc_internal_locations IS " + "'Unfiltered locations for the internal pygeoapi mount.'" + ) + ) + + +# All 22 relations this migration creates, in an order safe for DROP (the +# dependent view first, mirroring _recreate_all_internal_views's ordering). +ALL_INTERNAL_RELATIONS = [ + "ogc_internal_actively_monitored_wells", + *[f"ogc_internal_{view_id}" for view_id, _ in THING_VIEWS], + "ogc_internal_latest_depth_to_water_wells", + "ogc_internal_avg_tds_wells", + "ogc_internal_latest_tds_wells", + "ogc_internal_depth_to_water_trend_wells", + "ogc_internal_water_well_summary", + "ogc_internal_major_chemistry_results", + "ogc_internal_minor_chemistry_wells", + "ogc_internal_water_elevation_wells", + "ogc_internal_project_areas", + "ogc_internal_locations", +] + + +def upgrade() -> None: + _check_required_tables() + _recreate_all_internal_views() + + +def downgrade() -> None: + # None of these 22 relations existed before this migration -- unlike + # f4a5b6c7d8e9's downgrade (which recreates the prior unfiltered public + # views), there is no prior state to restore, so downgrade just drops + # everything this migration created. + for relation in ALL_INTERNAL_RELATIONS: + _drop_view_or_materialized_view(relation) diff --git a/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py b/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py index 3cc644393..eb403749d 100644 --- a/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py +++ b/alembic/versions/f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py @@ -468,6 +468,9 @@ def _create_water_well_summary_view(public_only: bool) -> str: # Static analyte columns for major chemistry pivots. # Includes aliases observed in current DB values (e.g., Ca(total), IONBAL, TAn, TCat, Na+K). +# Mirrored character-for-character (modulo view name) in +# 2d3c3a268652_create_internal_ogc_views.py; tests/test_migration_view_parity.py +# enforces the two stay in sync. STATIC_ANALYTE_COLUMNS_MAJOR: list[tuple[str, str]] = [ ("tds", "tds"), ("calcium", "calcium"), @@ -694,6 +697,9 @@ def _create_major_chemistry_results_view(public_only: bool) -> str: """ +# Mirrored character-for-character (modulo view name) in +# 2d3c3a268652_create_internal_ogc_views.py; tests/test_migration_view_parity.py +# enforces the two stay in sync. STATIC_ANALYTE_COLUMNS_MINOR: list[tuple[str, str]] = [ ("h2r", "h2r"), ("o18r", "o18r"), From 8eae5b17c70aa0ed432f5e4cf41f94b25bcf7ffd Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 17 Jul 2026 23:21:51 -0600 Subject: [PATCH 011/151] test(ogc): guard against analyte-mapping drift The major/minor chemistry CASE blocks now exist in two files (f4a5b6c7d8e9 and 2d3c3a268652) because migrations can't import from each other. Without a check, a future analyte-mapping fix applied to the public view (e.g. reported against a public-facing bug) has no reason to also touch the internal view sitting in a different file from a different ticket -- silently leaving internal staff looking at the stale mapping, which inverts who has the more reliable data. Compares AST source segments rather than raw text/regex so the comparison survives incidental formatting differences and only fails on a genuine logic change. Normalizes the one expected difference (the view name embedded in the CREATE statement) before comparing. --- tests/test_migration_view_parity.py | 76 +++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/test_migration_view_parity.py diff --git a/tests/test_migration_view_parity.py b/tests/test_migration_view_parity.py new file mode 100644 index 000000000..5c6035b80 --- /dev/null +++ b/tests/test_migration_view_parity.py @@ -0,0 +1,76 @@ +"""Drift detection between the public (A1) and internal (A11) OGC migrations. + +The two chemistry pivot views (major/minor) are dominated by long +analyte-alias CASE-mapping blocks that encode real lab-data business +knowledge. Because this codebase keeps Alembic migrations self-contained +with no cross-migration imports, that logic is duplicated rather than +shared between f4a5b6c7d8e9 (public) and 2d3c3a268652 (internal). If someone +fixes an analyte mapping in one file without the other, the public and +internal chemistry layers silently diverge -- this test turns that into a +loud, specific CI failure instead. +""" + +import ast +from pathlib import Path + +import pytest + +VERSIONS_DIR = Path(__file__).resolve().parent.parent / "alembic" / "versions" +PUBLIC_MIGRATION = ( + VERSIONS_DIR / "f4a5b6c7d8e9_apply_public_release_status_filter_to_ogc_views.py" +) +INTERNAL_MIGRATION = VERSIONS_DIR / "2d3c3a268652_create_internal_ogc_views.py" + +# Substrings that are expected to differ between the two files -- normalized +# away before comparison. Order matters: the internal_ variant must be +# stripped before its shorter public counterpart could ever match it. +NAME_SUBSTITUTIONS = [ + ("ogc_internal_major_chemistry_results", "ogc_major_chemistry_results"), + ("ogc_internal_minor_chemistry_wells", "ogc_minor_chemistry_wells"), +] + +COMPARED_NAMES = [ + "STATIC_ANALYTE_COLUMNS_MAJOR", + "STATIC_ANALYTE_COLUMNS_MINOR", + "_major_chemistry_select_columns", + "_major_chemistry_unit_columns", + "_minor_chemistry_value_columns", + "_minor_chemistry_unit_columns", + "_create_major_chemistry_results_view", + "_create_minor_chemistry_wells_view", +] + + +def _get_node_source(path: Path, name: str) -> str: + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == name: + return ast.get_source_segment(source, node) + if isinstance(node, ast.Assign): + targets = [t.id for t in node.targets if isinstance(t, ast.Name)] + if name in targets: + return ast.get_source_segment(source, node) + # STATIC_ANALYTE_COLUMNS_MAJOR/MINOR are annotated assignments + # (`NAME: list[...] = [...]`), which parse as AnnAssign, not Assign. + if isinstance(node, ast.AnnAssign): + if isinstance(node.target, ast.Name) and node.target.id == name: + return ast.get_source_segment(source, node) + raise AssertionError(f"{name!r} not found in {path}") + + +def _normalize(source: str) -> str: + for internal, public in NAME_SUBSTITUTIONS: + source = source.replace(internal, public) + return source + + +@pytest.mark.parametrize("name", COMPARED_NAMES) +def test_analyte_mapping_matches_between_public_and_internal_migrations(name): + public_source = _normalize(_get_node_source(PUBLIC_MIGRATION, name)) + internal_source = _normalize(_get_node_source(INTERNAL_MIGRATION, name)) + assert public_source == internal_source, ( + f"{name} has drifted between the public (f4a5b6c7d8e9) and internal " + "(2d3c3a268652) OGC migrations -- if this is a genuine analyte-mapping " + "fix, apply it to both files." + ) From f59a12ad0239a7687e0a8cd32db9d00ca9524682 Mon Sep 17 00:00:00 2001 From: Kelsey Smuczynski Date: Fri, 17 Jul 2026 23:22:49 -0600 Subject: [PATCH 012/151] test(ogc): add behave coverage for internal OGC mount Tags the 6 A11 scenarios A1 already wrote into this shared feature file with @production, appended to their existing tag lines rather than at the feature level -- this file has no feature-level tag since it's shared across ~10 other tickets' scenarios, and tagging at that level would pull in every other ticket's undefined steps. The 401/403/200 auth scenarios neutralize the ambient AUTHENTIK_DISABLE_AUTHENTICATION dev-bypass for their own duration only (restored via context.add_cleanup) and patch core.permissions.decode_token_payload directly, since no real Authentik server is available in CI to issue a genuine JWT. Existing auth-testing infrastructure in this repo only reaches app.dependency_overrides, which has zero effect on ASGI middleware -- none of it exercises InternalOGCAuthMiddleware for free. --- tests/features/ogc-cleanup-sprint1.feature | 12 +- tests/features/steps/ogc-cleanup-sprint1.py | 191 +++++++++++++++++++- 2 files changed, 193 insertions(+), 10 deletions(-) diff --git a/tests/features/ogc-cleanup-sprint1.feature b/tests/features/ogc-cleanup-sprint1.feature index c510bb0fc..c8c325dad 100644 --- a/tests/features/ogc-cleanup-sprint1.feature +++ b/tests/features/ogc-cleanup-sprint1.feature @@ -178,38 +178,38 @@ Feature: OGC Feature Layer Cleanup — Sprint 1 # A11 — Stand up authenticated internal OGC mount at /ogcapi-internal # --------------------------------------------------------------------------- - @backend @ogc-infrastructure @sprint-1 @high-priority @A11 + @backend @ogc-infrastructure @sprint-1 @high-priority @A11 @production Scenario: Anonymous request to internal OGC endpoint is rejected When an unauthenticated client requests /ogcapi-internal/collections Then the response HTTP status is 401 - @backend @ogc-infrastructure @sprint-1 @high-priority @A11 + @backend @ogc-infrastructure @sprint-1 @high-priority @A11 @production Scenario: Request with insufficient role to internal OGC endpoint is rejected Given the client presents a valid token with role "public-viewer" When the client requests /ogcapi-internal/collections Then the response HTTP status is 403 - @backend @ogc-infrastructure @sprint-1 @high-priority @A11 + @backend @ogc-infrastructure @sprint-1 @high-priority @A11 @production Scenario: Authenticated internal staff can access /ogcapi-internal collections Given an internal staff member with the required role is authenticated via Authentik When the staff member requests /ogcapi-internal/collections Then the response HTTP status is 200 And the response includes collections not available on the public /ogcapi endpoint - @backend @ogc-infrastructure @sprint-1 @high-priority @A11 + @backend @ogc-infrastructure @sprint-1 @high-priority @A11 @production Scenario: Internal collections expose private and draft records Given an authenticated internal staff member When the staff member requests items from the "water_wells" internal collection Then records with a release_status other than "public" are included in the response - @backend @ogc-infrastructure @sprint-1 @high-priority @A11 + @backend @ogc-infrastructure @sprint-1 @high-priority @A11 @production Scenario: Internal database relations are separate from public relations Given the /ogcapi-internal mount has been deployed When the database schema is inspected Then the database schema contains relations prefixed with "ogc_internal_" And no ogc_internal_ relation is shared with the public /ogcapi endpoint - @backend @ogc-infrastructure @sprint-1 @high-priority @A11 + @backend @ogc-infrastructure @sprint-1 @high-priority @A11 @production Scenario: Public /ogcapi surface is unaffected by the internal mount When a client requests /ogcapi/collections Then no collection in the response has an id prefixed "ogc_internal_" diff --git a/tests/features/steps/ogc-cleanup-sprint1.py b/tests/features/steps/ogc-cleanup-sprint1.py index 5c2f3e5ee..51e744d8a 100644 --- a/tests/features/steps/ogc-cleanup-sprint1.py +++ b/tests/features/steps/ogc-cleanup-sprint1.py @@ -13,15 +13,18 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -"""Step definitions for A1 (public release_status filter on ogc_* views). +"""Step definitions for A1 (public release_status filter on ogc_* views) and +A11 (authenticated internal OGC mount at /ogcapi-internal). -Only the @A1-tagged scenarios in ogc-cleanup-sprint1.feature are implemented -here. The other ~10 tickets sharing that feature file have no steps yet and -stay undefined/dormant, per this ticket's plan. +Only the @A1- and @A11-tagged scenarios in ogc-cleanup-sprint1.feature are +implemented here. The other ~9 tickets sharing that feature file have no +steps yet and stay undefined/dormant, per this ticket's plan. """ import importlib +import os from datetime import date +from unittest.mock import patch from alembic import command from behave import given, when, then @@ -34,6 +37,7 @@ admin_function, amp_admin_function, ) +from core.permissions import INTERNAL_OGC_GROUP from starlette.testclient import TestClient from db import ( @@ -670,4 +674,183 @@ def step_then_same_feature_count_as_before(context): ), f"{layer_id}: expected {before_count} features (unchanged), got {after_count}" +# --------------------------------------------------------------------------- +# A11 -- authenticated internal OGC mount (/ogcapi-internal) +# --------------------------------------------------------------------------- +# +# tests/test_pygeoapi_mount.py's existing coverage and the "a functioning +# api" step above both only touch FastAPI's app.dependency_overrides, which +# has zero effect on ASGI middleware -- none of this codebase's existing +# auth-testing infrastructure reaches InternalOGCAuthMiddleware for free. +# The 401/403/200 scenarios below instead neutralize the ambient +# AUTHENTIK_DISABLE_AUTHENTICATION dev-bypass (set for the whole bdd-tests CI +# job) for the scenario's duration only, and control +# core.permissions.decode_token_payload's return value directly, since no +# real Authentik server is available in CI to issue a genuine JWT. + + +def _neutralize_authentik_bypass(context): + """Temporarily force the dev-bypass off so InternalOGCAuthMiddleware's + real 401/403 logic actually runs, restored unconditionally afterward so + later scenarios relying on the global CI dev-bypass are unaffected. + """ + original = os.environ.get("AUTHENTIK_DISABLE_AUTHENTICATION") + os.environ["AUTHENTIK_DISABLE_AUTHENTICATION"] = "0" + + def _restore(): + if original is None: + os.environ.pop("AUTHENTIK_DISABLE_AUTHENTICATION", None) + else: + os.environ["AUTHENTIK_DISABLE_AUTHENTICATION"] = original + + context.add_cleanup(_restore) + + +def _patch_decode_token_payload(context, groups): + patcher = patch( + "core.permissions.decode_token_payload", return_value={"groups": groups} + ) + patcher.start() + context.add_cleanup(patcher.stop) + + +def _teardown_a11_seed_data(): + with session_ctx() as session: + session.execute(text("DELETE FROM thing WHERE name LIKE 'A11 %'")) + session.commit() + + +@when("an unauthenticated client requests /ogcapi-internal/collections") +def step_when_unauthenticated_client_requests_internal_collections(context): + _neutralize_authentik_bypass(context) + context.response = context.client.get("/ogcapi-internal/collections") + + +@given('the client presents a valid token with role "{role}"') +def step_given_client_presents_token_with_role(context, role): + _neutralize_authentik_bypass(context) + _patch_decode_token_payload(context, groups=[role]) + context.auth_token = "a11-behave-test-token" + + +@when("the client requests /ogcapi-internal/collections") +def step_when_client_requests_internal_collections(context): + headers = {"Authorization": f"Bearer {context.auth_token}"} + context.response = context.client.get( + "/ogcapi-internal/collections", headers=headers + ) + + +@given("an internal staff member with the required role is authenticated via Authentik") +def step_given_internal_staff_authenticated_via_authentik(context): + _neutralize_authentik_bypass(context) + _patch_decode_token_payload(context, groups=[INTERNAL_OGC_GROUP]) + context.auth_token = "a11-behave-test-token" + + +@when("the staff member requests /ogcapi-internal/collections") +def step_when_staff_member_requests_internal_collections(context): + headers = {"Authorization": f"Bearer {context.auth_token}"} + context.response = context.client.get( + "/ogcapi-internal/collections", headers=headers + ) + + +@then("the response includes collections not available on the public /ogcapi endpoint") +def step_then_response_includes_internal_only_collections(context): + payload = context.response.json() + collections = payload.get("collections", []) + assert collections, "internal endpoint returned no collections" + for collection in collections: + links = collection.get("links", []) + assert any("/ogcapi-internal" in link.get("href", "") for link in links), ( + f"{collection.get('id')}: no self-link referencing /ogcapi-internal -- " + "expected each internal collection representation to be reachable " + "only via the internal mount, not the public /ogcapi endpoint" + ) + + +@given("an authenticated internal staff member") +def step_given_an_authenticated_internal_staff_member(context): + # Relies on the ambient AUTHENTIK_DISABLE_AUTHENTICATION dev-bypass (set + # for the whole CI job) rather than a real token -- this scenario is + # about what the internal mount exposes, not auth semantics (covered + # separately by the 401/403/200 scenarios above). + with session_ctx() as session: + context.a11_seed_ids = {} + for status in STATUSES: + thing = _seed_thing_with_location( + session, "water well", status, f"A11 {status}" + ) + context.a11_seed_ids[status] = thing.id + context.add_cleanup(_teardown_a11_seed_data) + + +@when('the staff member requests items from the "{layer_id}" internal collection') +def step_when_staff_member_requests_internal_collection_items(context, layer_id): + context.response = context.client.get( + f"/ogcapi-internal/collections/{layer_id}/items?limit=500" + ) + + +@then('records with a release_status other than "public" are included in the response') +def step_then_non_public_records_included(context): + payload = context.response.json() + ids_present = _layer_feature_ids(payload) + non_public_ids = {context.a11_seed_ids["private"], context.a11_seed_ids["draft"]} + assert non_public_ids & ids_present, ( + "expected the internal collection to include the seeded private/draft " + f"wells {non_public_ids}, got ids {ids_present}" + ) + + +@given("the /ogcapi-internal mount has been deployed") +def step_given_internal_mount_has_been_deployed(context): + command.upgrade(_alembic_config(), "head") + + +@when("the database schema is inspected") +def step_when_database_schema_is_inspected(context): + with session_ctx() as session: + context.schema_relations = set( + session.execute( + text( + "SELECT c.relname FROM pg_class c " + "JOIN pg_namespace n ON n.oid = c.relnamespace " + "WHERE c.relkind IN ('v', 'm') AND n.nspname = 'public'" + ) + ).scalars() + ) + + +@then('the database schema contains relations prefixed with "{prefix}"') +def step_then_schema_contains_relations_prefixed(context, prefix): + matching = {r for r in context.schema_relations if r.startswith(prefix)} + assert matching, f"expected at least one relation prefixed {prefix!r}, found none" + + +@then("no ogc_internal_ relation is shared with the public /ogcapi endpoint") +def step_then_no_internal_relation_shared_with_public(context): + internal = {r for r in context.schema_relations if r.startswith("ogc_internal_")} + assert internal, "no ogc_internal_ relations found in the schema" + for relation in internal: + public_equivalent = relation.replace("ogc_internal_", "ogc_", 1) + assert public_equivalent in context.schema_relations, ( + f"{relation} has no distinct public counterpart ({public_equivalent}) in " + "the schema -- expected the two sets to coexist as separate relations" + ) + + +@when("a client requests /ogcapi/collections") +def step_when_client_requests_ogcapi_collections(context): + context.response = context.client.get("/ogcapi/collections") + + +@then('no collection in the response has an id prefixed "{prefix}"') +def step_then_no_collection_id_prefixed(context, prefix): + payload = context.response.json() + offending = [c["id"] for c in payload["collections"] if c["id"].startswith(prefix)] + assert not offending, f"found collections with id prefixed {prefix!r}: {offending}" + + # ============= EOF ============================================= From 36221e7b95597bfdbf4b283787db1cc0d8d015e4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:18:54 +0000 Subject: [PATCH 013/151] chore: sync staging release-please manifest to v1.2.0 --- .release-please-manifest.staging.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.release-please-manifest.staging.json b/.release-please-manifest.staging.json index fa8324b78..c3f146397 100644 --- a/.release-please-manifest.staging.json +++ b/.release-please-manifest.staging.json @@ -1,3 +1,3 @@ { - ".": "1.2.0-rc.1" + ".": "1.2.0" } From 3bee7d50f3537afacd3cb42344e4d67b3dac39ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:18:58 +0000 Subject: [PATCH 014/151] chore: sync uv.lock to released version 1.2.0 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 09ab41b99..3bc143c1f 100644 --- a/uv.lock +++ b/uv.lock @@ -1552,7 +1552,7 @@ wheels = [ [[package]] name = "ocotilloapi" -version = "1.1.5" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From ed9e4b98df4639646fdad9e9808be2225e5da471 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:06:28 +0000 Subject: [PATCH 015/151] build(deps): bump astral-sh/setup-uv from 8.3.2 to 9.0.0 Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 8.3.2 to 9.0.0. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/v8.3.2...v9.0.0) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 9.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/CD_production.yml | 2 +- .github/workflows/CD_staging.yml | 2 +- .github/workflows/CD_testing.yml | 2 +- .github/workflows/forward-merge.yml | 4 ++-- .github/workflows/jira_codex_pr.yml | 2 +- .github/workflows/tests.yml | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 0017e25a5..d184502d2 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -54,7 +54,7 @@ jobs: ref: refs/tags/${{ env.DEPLOY_TAG }} - name: Install uv in container - uses: astral-sh/setup-uv@v8.3.2 + uses: astral-sh/setup-uv@v9.0.0 with: version: "latest" diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 232981071..c17dce995 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - name: Install uv in container - uses: astral-sh/setup-uv@v8.3.2 + uses: astral-sh/setup-uv@v9.0.0 with: version: "latest" diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index f9c0ac890..8967e26dc 100644 --- a/.github/workflows/CD_testing.yml +++ b/.github/workflows/CD_testing.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - name: Install uv in container - uses: astral-sh/setup-uv@v8.3.2 + uses: astral-sh/setup-uv@v9.0.0 with: version: "latest" diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml index c84a540c9..d71aa731b 100644 --- a/.github/workflows/forward-merge.yml +++ b/.github/workflows/forward-merge.yml @@ -103,7 +103,7 @@ jobs: # the lockfile is re-locked (see commit 27751110). Idempotent: no # lockfile change -> no commit. - name: Install uv - uses: astral-sh/setup-uv@v8.3.2 + uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true cache-dependency-glob: uv.lock @@ -166,7 +166,7 @@ jobs: # push. Plain push (not force) so an out-of-date checkout fails loudly # instead of clobbering newer hotfix commits. - name: Install uv - uses: astral-sh/setup-uv@v8.3.2 + uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true cache-dependency-glob: uv.lock diff --git a/.github/workflows/jira_codex_pr.yml b/.github/workflows/jira_codex_pr.yml index ba9f368da..7369a40d4 100644 --- a/.github/workflows/jira_codex_pr.yml +++ b/.github/workflows/jira_codex_pr.yml @@ -59,7 +59,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Set up uv (with cache) - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v4 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v4 with: enable-cache: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a82d54d46..6be087806 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,7 +63,7 @@ jobs: exit 1 - name: Install uv - uses: astral-sh/setup-uv@v8.3.2 + uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true cache-dependency-glob: uv.lock @@ -155,7 +155,7 @@ jobs: exit 1 - name: Install uv - uses: astral-sh/setup-uv@v8.3.2 + uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true cache-dependency-glob: uv.lock From 99c1b5d0840de6f6f7e7ef4d613f635185278c9c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:06:37 +0000 Subject: [PATCH 016/151] build(deps): bump actions/checkout from 7.0.0 to 7.0.1 in the gha-minor-and-patch group (#793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the gha-minor-and-patch group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 7.0.0 to 7.0.1
Release notes

Sourced from actions/checkout's releases.

v7.0.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v7...v7.0.1

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=7.0.0&new-version=7.0.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 major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/CD_production.yml | 2 +- .github/workflows/CD_staging.yml | 2 +- .github/workflows/CD_testing.yml | 2 +- .github/workflows/format_code.yml | 4 ++-- .github/workflows/forward-merge.yml | 4 ++-- .github/workflows/hotfix-start.yml | 2 +- .github/workflows/jira_codex_pr.yml | 2 +- .github/workflows/release-please.yml | 2 +- .github/workflows/tests.yml | 4 ++-- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 0017e25a5..21c6acc07 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -46,7 +46,7 @@ jobs: fi - name: Check out source repository - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: fetch-depth: 0 # Fully-qualified tag ref avoids ambiguity if a branch is ever diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 232981071..348f2b4b8 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Check out source repository - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index f9c0ac890..ba4d4e790 100644 --- a/.github/workflows/CD_testing.yml +++ b/.github/workflows/CD_testing.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Check out source repository - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/format_code.yml b/.github/workflows/format_code.yml index 6eb001bed..7e9797129 100644 --- a/.github/workflows/format_code.yml +++ b/.github/workflows/format_code.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out source repository - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 - name: Set up Python environment - 3.12 uses: actions/setup-python@v7.0.0 with: @@ -34,7 +34,7 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: ref: ${{ github.head_ref }} - uses: psf/black@stable diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml index c84a540c9..ccb788edf 100644 --- a/.github/workflows/forward-merge.yml +++ b/.github/workflows/forward-merge.yml @@ -54,7 +54,7 @@ jobs: GH_TOKEN: ${{ secrets.FORWARD_MERGE_TOKEN || github.token }} TAG: ${{ inputs.tag_name }} steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: fetch-depth: 0 token: ${{ secrets.FORWARD_MERGE_TOKEN || github.token }} @@ -149,7 +149,7 @@ jobs: TAG: ${{ inputs.tag_name }} SOURCE: ${{ inputs.source_branch }} steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: fetch-depth: 0 ref: ${{ inputs.source_branch }} diff --git a/.github/workflows/hotfix-start.yml b/.github/workflows/hotfix-start.yml index 6f1a81e4c..bec2fcbcc 100644 --- a/.github/workflows/hotfix-start.yml +++ b/.github/workflows/hotfix-start.yml @@ -24,7 +24,7 @@ jobs: create-hotfix-branch: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/jira_codex_pr.yml b/.github/workflows/jira_codex_pr.yml index ba9f368da..36a5a8841 100644 --- a/.github/workflows/jira_codex_pr.yml +++ b/.github/workflows/jira_codex_pr.yml @@ -41,7 +41,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 with: fetch-depth: 0 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index bac76bf49..3b32a2cd9 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -37,7 +37,7 @@ jobs: # configs set include-v-in-tag: true, so the tag is `v`. Prefer # the action's own output if it is ever non-empty. - if: ${{ steps.release.outputs.release_created == 'true' }} - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 - id: resolve_tag if: ${{ steps.release.outputs.release_created == 'true' }} env: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a82d54d46..57883f562 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,7 +49,7 @@ jobs: steps: - name: Check out source repository - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 - name: Wait for database readiness run: | @@ -141,7 +141,7 @@ jobs: steps: - name: Check out source repository - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 - name: Wait for database readiness run: | From 2ddffd7e973a2cb734bc8656a8a5d7a20fd6cf84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:22:35 +0000 Subject: [PATCH 017/151] build(deps): bump the uv-non-major group with 18 updates (#795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the uv-non-major group with 18 updates: | Package | From | To | | --- | --- | --- | | [aiohttp](https://github.com/aio-libs/aiohttp) | `3.14.1` | `3.14.3` | | [annotated-types](https://github.com/annotated-types/annotated-types) | `0.7.0` | `0.8.0` | | [cachetools](https://github.com/tkem/cachetools) | `7.1.4` | `7.1.6` | | [certifi](https://github.com/certifi/python-certifi) | `2026.6.17` | `2026.7.22` | | [cloud-sql-python-connector](https://github.com/GoogleCloudPlatform/cloud-sql-python-connector) | `1.20.4` | `1.21.0` | | [fastapi](https://github.com/fastapi/fastapi) | `0.139.2` | `0.140.2` | | [google-api-core](https://github.com/googleapis/google-cloud-python) | `2.32.0` | `2.33.0` | | [google-auth](https://github.com/googleapis/google-cloud-python) | `2.56.0` | `2.56.2` | | [greenlet](https://github.com/python-greenlet/greenlet) | `3.5.3` | `3.5.4` | | [phonenumbers](https://github.com/daviddrysdale/python-phonenumbers) | `9.0.34` | `9.0.35` | | [proto-plus](https://github.com/googleapis/google-cloud-python) | `1.28.1` | `1.28.2` | | [pytz](https://github.com/stub42/pytz) | `2026.2` | `2026.3.post1` | | [scramp](https://github.com/tlocke/scramp) | `1.4.12` | `1.4.15` | | [sentry-sdk[fastapi]](https://github.com/getsentry/sentry-python) | `2.66.0` | `2.66.1` | | [pre-commit](https://github.com/pre-commit/pre-commit) | `4.6.0` | `4.6.1` | | [google-api-python-client](https://github.com/googleapis/google-api-python-client) | `2.184.0` | `2.198.0` | | [filelock](https://github.com/tox-dev/py-filelock) | `3.31.1` | `3.32.0` | | [sentry-sdk](https://github.com/getsentry/sentry-python) | `2.66.0` | `2.66.1` | Updates `aiohttp` from 3.14.1 to 3.14.3 Updates `annotated-types` from 0.7.0 to 0.8.0
Release notes

Sourced from annotated-types's releases.

v0.8.0

What's Changed

New Contributors

Full Changelog: https://github.com/annotated-types/annotated-types/compare/v0.7.0...v0.8.0

Commits

Updates `cachetools` from 7.1.4 to 7.1.6
Changelog

Sourced from cachetools's changelog.

v7.1.6 (2026-07-24)

  • Minor style improvements to keep ruff happy.

v7.1.5 (2026-07-23)

  • Fix TLRUCache silently keeping stale values on expired overwrites.

  • Reject negative cache item getsizeof values.

  • Update build environment.

Commits
  • 13bb86a Minor style improvements to keep ruff happy.
  • e2250be Fix RTD version handling.
  • 0d2a6ea Release v7.1.5.
  • d64cf80 Prepare v7.1.5.
  • fcbb0de Fix #406: Merge branch 'gaoflow-fix-tlru-overwrite-expired-stale-value' into ...
  • c0fdf6a Fix TLRUCache silently keeping stale value on expired overwrite
  • 978d34d Bump actions/setup-python from 6.2.0 to 6.3.0
  • d5c7eea Reject negative cache item sizes
  • 578e976 Update build environment.
  • e164b70 Bump codecov/codecov-action from 6.0.0 to 7.0.0
  • Additional commits viewable in compare view

Updates `certifi` from 2026.6.17 to 2026.7.22
Commits

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

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

v1.21.0

1.21.0 (2026-07-23)

Features

  • Add PSC DNS and Global Write Endpoint support to Python Connector (#1424) (b34e9c6)

Bug Fixes

Changelog

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

1.21.0 (2026-07-23)

Features

  • Add PSC DNS and Global Write Endpoint support to Python Connector (#1424) (b34e9c6)

Bug Fixes

Commits

Updates `fastapi` from 0.139.2 to 0.140.2
Release notes

Sourced from fastapi's releases.

0.140.2

Refactors

Internal

0.140.1

Refactors

  • ♻️ Update the lru_cache limit for dependencies to account for large apps. PR #16062 by @​tiangolo.

0.140.0

Refactors

Docs

Internal

Commits

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

Sourced from google-api-core's releases.

google-api-core: v2.33.0

2.33.0 (2026-07-22)

Features

  • api_core: add request-id auto-population logic to gapic_v1 public helpers (#17738) (68e1313)
  • api-core: add get_universe_domain helper to universe.py (#17799) (d461da7)

Bug Fixes

  • api-core: prevent overwriting explicit empty strings for optional request_id (#17798) (07f7503)
Commits
  • 6702c7a chore: release main (#17809)
  • 6ab2fe1 chore(main): release google-cloud-productregistry 0.1.0 (#17806)
  • cc109ca chore(main): release google-auth 2.56.2 (#17814)
  • 9668675 chore: update librarian to v0.28.0 (#17811)
  • d4b76fa chore(sqlalchemy-bigquery): restore complete coverage (#17770)
  • df0541a fix(gapic): mock os.path.exists in mTLS tests to support newer google auth (#...
  • 1a10e23 chore(main): release google-maps-isochrones 0.1.0 (#17804)
  • 2a2dc83 test(bigquery): add debug info to socket leak test (#17802)
  • b2d3fb9 chore(handwritten): centralize mypy configuration and update handwritten pack...
  • 2a7d346 tests: remove protobuf cpp from tests which is no longer used as of Protobuf ...
  • Additional commits viewable in compare view

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

Sourced from google-auth's releases.

google-auth: v2.56.2

2.56.2 (2026-07-21)

Bug Fixes

  • auth: centralize cert discovery logic and steps (#17696) (edc0423)
  • auth: exit early when agent cert config is outside well-known directory (#17762) (61e795a)
  • transport: propagate mTLS adapter to auth session and fix connection leaks (#17689) (8289d32)
  • update _SERVICE_ACCOUNT_EMAIL_PATTERN to require .gserviceaccount.com suffix (#17748) (b60bb04)

google-auth: v2.56.1

2.56.1 (2026-07-17)

Bug Fixes

Commits
  • cc109ca chore(main): release google-auth 2.56.2 (#17814)
  • d4b76fa chore(sqlalchemy-bigquery): restore complete coverage (#17770)
  • df0541a fix(gapic): mock os.path.exists in mTLS tests to support newer google auth (#...
  • 1a10e23 chore(main): release google-maps-isochrones 0.1.0 (#17804)
  • 2a2dc83 test(bigquery): add debug info to socket leak test (#17802)
  • b2d3fb9 chore(handwritten): centralize mypy configuration and update handwritten pack...
  • 2a7d346 tests: remove protobuf cpp from tests which is no longer used as of Protobuf ...
  • fb5aada chore(main): release google-cloud-commerceproducer 0.1.0 (#17805)
  • b60bb04 fix: update _SERVICE_ACCOUNT_EMAIL_PATTERN to require .gserviceaccount.com ...
  • ed25698 tests(sqlalchemy-bigquery): resolve ST function type binding bug (#17769)
  • Additional commits viewable in compare view

Updates `greenlet` from 3.5.3 to 3.5.4
Changelog

Sourced from greenlet's changelog.

3.5.4 (2026-07-22)

  • Fix a crash (segfault) on free-threaded builds of Python 3.14 and later when the garbage collector runs while a greenlet that was started from a non-empty C-stack-reference state is active. See issue 515 <https://github.com/python-greenlet/greenlet/issues/515>_. Thanks to ddorian and Kumar Aditya.

  • Fix a potential use-after-free on free-threaded builds of Python 3.14 and later when the garbage collector runs while a greenlet is suspended holding a _PyCStackRef (for example, mid attribute resolution). See issue 515 <https://github.com/python-greenlet/greenlet/issues/515>_. Thanks to ddorian and Kumar Aditya.

  • Fix a deadlock on free-threaded builds when a greenlet switch happened while a PyCriticalSection was held -- for example inside asyncio's Task.__step, which holds one on the running task for the duration of the step. See PR 519 <https://github.com/python-greenlet/greenlet/pull/519/>. Thanks to ddorian and Kumar Aditya.

.. note:: Binary 3.15 wheels are now built with Python 3.15b4. These may not be compatible with earlier or later versions of 3.15. Binary 3.15 wheels of greenlet from previous releases (e.g., 3.5.3) may not be compatible with Python 3.15b4.

Commits
  • 384be88 Preparing release 3.5.4
  • bbdf57b Add note to CHANGES about versions of 3.15 binary wheels may/not be compatibl...
  • 7599023 Merge pull request #517 from ddorian/issue515-c-stack-refs
  • 73a0a1a Fix the suspended C-stack-ref GC test to catch its regression
  • 1ff35f4 Merge branch 'master' into issue515-c-stack-refs
  • 847fb82 Merge pull request #520 from dynapx/fix-test-extension-npd
  • 0b471ba Merge pull request #519 from ddorian/freethread-switch-critical-section
  • d55914d Simplify the CHANGES.rst entry
  • 78932dc Change notes are for end users, they don't need to go into technical detail
  • 39bf70c Hold the C-stack ref snapshot in a std::vector<OwnedObject>
  • Additional commits viewable in compare view

Updates `phonenumbers` from 9.0.34 to 9.0.35
Commits

Updates `proto-plus` from 1.28.1 to 1.28.2
Release notes

Sourced from proto-plus's releases.

proto-plus: v1.28.2

1.28.2 (2026-07-22)

Bug Fixes

  • proto-plus: make Marshal thread-safe and handle race conditions (#17774) (0719f1e), closes #15100
Commits
  • 6702c7a chore: release main (#17809)
  • 6ab2fe1 chore(main): release google-cloud-productregistry 0.1.0 (#17806)
  • cc109ca chore(main): release google-auth 2.56.2 (#17814)
  • 9668675 chore: update librarian to v0.28.0 (#17811)
  • d4b76fa chore(sqlalchemy-bigquery): restore complete coverage (#17770)
  • df0541a fix(gapic): mock os.path.exists in mTLS tests to support newer google auth (#...
  • 1a10e23 chore(main): release google-maps-isochrones 0.1.0 (#17804)
  • 2a2dc83 test(bigquery): add debug info to socket leak test (#17802)
  • b2d3fb9 chore(handwritten): centralize mypy configuration and update handwritten pack...
  • 2a7d346 tests: remove protobuf cpp from tests which is no longer used as of Protobuf ...
  • Additional commits viewable in compare view

Updates `pytz` from 2026.2 to 2026.3.post1
Commits
  • 661bca9 Bump version numbers to 2026.3.post1 for python2 fix
  • 1e31a16 Log python version running tests, force python2
  • b3ca7c3 Unix line endings
  • b55039a Replace non-ASCII character in comment to fix build with Python 2
  • 5420ee2 Replace non-ASCII character in comment
  • 2c139e8 Merge branch 'fix/localize-overflow-at-datetime-extremes' of https://github.c...
  • c843864 Run zdump tests quietly
  • 518500c Reduce noise when collecting zdump info dumps
  • 081f935 Merge branch 'kytta-fix-dst' into 2026c
  • 8c9d69b Merge branch 'master' into 2026c
  • Additional commits viewable in compare view

Updates `scramp` from 1.4.12 to 1.4.15
Commits

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

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

2.66.1

Bug Fixes 🐛

Tracing

Internal Changes 🔧

Changelog

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

2.66.1

Bug Fixes 🐛

Tracing

Internal Changes 🔧

Commits
  • 653a292 Update CHANGELOG.md
  • e20c226 release: 2.66.1
  • 4524b65 fix(tracing): Stop setting NoOpSpan on scope in the streaming trace lifecyc...
  • 2e9f26e ref(tracing): No-op and emit warning in start_transaction with the streamin...
  • 3a50950 fix(tracing): handle exceptions raised within traces_sampler and other callba...
  • b5171b7 ref: Use top-level trace_lifecycle and ignore_spans options in tests (#6855)
  • 17e0348 ref: Use old sampling context format in span streaming (#6848)
  • 2e09497 test: Add streaming tests to test_http_headers (#6785)
  • 9996734 feat(tracing): Send sentry.segment.name.source instead of `sentry.span.sour...
  • 00224f7 remove span streaming docs in changelog (#6831)
  • Additional commits viewable in compare view

Updates `pre-commit` from 4.6.0 to 4.6.1
Release notes

Sourced from pre-commit's releases.

pre-commit v4.6.1

Fixes

Changelog

Sourced from pre-commit's changelog.

4.6.1 - 2026-07-21

Fixes

Commits
  • 242ce8a v4.6.1
  • 766e550 Merge pull request #3727 from pre-commit/dedupe
  • 1558d06 Merge pull request #3726 from pre-commit/exists-faster
  • 8a1c47a avoid duplicate files in --all-files during conflict
  • 2e01c99 faster check of rev existing locally as a commit
  • 3613bf2 Merge pull request #3701 from pre-commit/autoupdate-repos
  • 1d811d9 Return an error for invalid --repo
  • 374d354 Merge pull request #3711 from damonbayer/dmb_JULIA_DEPOT_PATH
  • 1e7994f set JULIA_DEPOT_PATH
  • b2b9119 Merge pull request #3719 from pre-commit/npm-unknown-options
  • Additional commits viewable in compare view

Updates `google-api-python-client` from 2.184.0 to 2.198.0
Release notes

Sourced from google-api-python-client's releases.

v2.198.0

2.198.0 (2026-06-23)

Features

feature

  • [feature] [autogenerate] Autogenerate now detects the addition and removal of named CHECK constraints, as part of the default autogenerate behavior. Detection is name-based only; a constraint whose name is unchanged is presumed equivalent regardless of its expression text, as reliably normalizing SQL expressions across backends for comparison purposes is not generally feasible. This behavior is implemented as a plugin named alembic.autogenerate.checkconstraint_byname, and may be disabled if not desired by excluding it from the EnvironmentContext.configure.autogenerate_plugins list. Pull request courtesy Francois van Kempen.

    References: #508

bug

  • [bug] [commands] Fixed inconsistency where running stamp or downgrade to base in offline (--sql) mode would emit a DROP TABLE alembic_version statement, while the same operations in online mode never drop the version table. Offline mode no longer emits this DROP, matching online behavior. The version table continues to be created when it does not exist; only the spurious offline-only drop has been removed. Pull request courtesy imurodl.

    References: #1822

Commits

Updates `cachetools` from 7.1.6 to 7.1.7
Changelog

Sourced from cachetools's changelog.

v7.1.7 (2026-08-01)

  • Improve Cache.__setitem__ behavior when replacing an existing cache item with a larger value.

  • Update CI environment.

Commits

Updates `cffi` from 2.1.0 to 2.1.1
Release notes

Sourced from cffi's releases.

v2.1.1

What's Changed

  • Minimize internal Python API usage for interpreter and thread state sampling where possible. Avoids breaking ABI change in Python >= 3.15.0b4 (python-cffi/cffi#269).

Full Changelog: https://github.com/python-cffi/cffi/compare/v2.1.0...v2.1.1

Commits

Updates `fastapi` from 0.140.2 to 0.141.1
Release notes

Sourced from fastapi's releases.

0.141.1

Fixes

  • 🐛 Fix support for background tasks and headers from dependencies in app.frontend(). PR #16105 by @​tiangolo.

Docs

0.141.0

Features

  • ✨ Add app.frontend(check_dir="auto"), to make local development more convenient with fastapi dev. PR #16102 by @​tiangolo.

0.140.13

Fixes

Docs

0.140.12

Fixes

0.140.11

Fixes

  • 🐛 Fix response_model_* params ignored for non-generator endpoints with Iterable[..] return type. PR #15093 by @​YuriiMotov.

0.140.10

Fixes

Internal

0.140.9

Fixes

  • 🐛 Fix exclude_defaults not propagated to dict keys and values in jsonable_encoder. PR #16043 by @​MBGrao.

... (truncated)

Commits
  • 95f8322 🔖 Release version 0.141.1 (#16106)
  • f137944 📝 Update release notes
  • d623544 🐛 Fix support for background tasks and headers from dependencies in `app.fron...
  • 1d211b9 📝 Update release notes
  • 8a1f876 📝 Document FASTAPI_ENV in FastAPI CLI guide (#16104)
  • c7e7b65 🔖 Release version 0.141.0 (#16103)
  • 6bceb84 📝 Update release notes
  • 5429fed ✨ Add app.frontend(check_dir="auto"), to make local development more conven...
  • 628663f 🔖 Release version 0.140.13 (#16096)
  • 0b54fd0 📝 Update release notes
  • Additional commits viewable in compare view

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

Sourced from fastapi-pagination's releases.

0.15.16

What's Changed

  • Support new fastapi version 0.140.5+. #1990

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

Commits

Updates `google-api-core` from 2.33.0 to 2.34.0
Release notes

Sourced from google-api-core's releases.

google-api-core: v2.34.0

2.34.0 (2026-08-06)

Features

Bug Fixes

  • api-core: use truthiness check in setup_request_id to support proto-plus messages (#18000) (ad8f93c)
  • bump grpcio to 1.59.0; require Python 3.10+ (#17351) (a53487a)
  • deduplicate x-goog-api-client headers (#17616) (6167e41)
  • require Protobuf 6.33.5+ (#17743) (d267342)
Commits
  • 905bfe3 chore: release main (#17832)
  • ad8f93c fix(api-core): use truthiness check in setup_request_id to support proto-plus...
  • 06dd2c0 chore: bump google-api-core to 2.28.0 (#18003)
  • 2619725 chore: remove bigtable and sqlalchemy-spanner from bulk release due to faili...
  • 56b6bd2 fix(ci): skip loaded lines calculation on iterations after the first (#17790)
  • 08f21a6 fix(proto-plus): add context to TypeErrors during message manipulation (#17682)
  • 32fd479 tests(spanner): avoid table name collisions in tests (#18001)
  • 2207ca6 feat: add pandas-gbq capability helper (#17957)
  • 6167e41 fix: deduplicate x-goog-api-client headers (#17616)
  • 71bc622 docs: add connector libraries overview table to package README (#17939)
  • Additional commits viewable in compare view

Updates `google-auth` from 2.56.2 to 2.56.3
Release notes

Sourced from google-auth's releases.

google-auth: v2.56.3

2.56.3 (2026-08-06)

Bug Fixes

  • auth: avoid creating mTLS SSL context for custom async transports (#17825) (fbe33f9), refs #17622
  • auth: only trigger mTLS certificate rotation on mTLS endpoints (#17928) (f7b49ea)
  • auth: properly extract stdout from gnubby webauthn plugin failures (#17885) (744e826)
  • deduplicate x-goog-api-client headers (#17616) (6167e41)
  • oauth2: avoid redundant JWKS network fetches (#17891) (de53298)

Performance Improvements

  • auth: use generator expression in any() to allow short-circuiting (735e565)
  • auth: use generator expression in any() to allow short-circuiting (#17937) (735e565)
Commits
  • 905bfe3 chore: release main (#17832)
  • ad8f93c fix(api-core): use truthiness check in setup_request_id to support proto-plus...
  • 06dd2c0 chore: bump google-api-core to 2.28.0 (#18003)
  • 08f21a6 fix(proto-plus): add context to TypeErrors during message manipulation (#17682)
  • 32fd479 tests(spanner): avoid table name collisions in tests (#18001)
  • 2207ca6 feat: add pandas-gbq capability helper (#17957)
  • 6167e41 fix: deduplicate x-goog-api-client headers (#17616)
  • 71bc622 docs: add connector libraries overview table to package README (#17939)
  • 1b5c48b fix(bigframes): fix field name typos for ai.generate* functions (#17983)
  • f64ada2 fix: avoid retaining routing parameter instances in cache (#17961)
  • Additional commits viewable in compare view

Updates `google-cloud-core` from 2.6.0 to 2.6.1
Release notes

Sourced from google-cloud-core's releases.

google-cloud-core: v2.6.1

2.6.1 (2026-08-06)

Bug Fixes

Changelog

Sourced from google-cloud-core's changelog.

Changelog

PyPI History

3.15.0 (2026-06-02)

Features

3.14.0 (2026-04-02)

Features

3.13.0 (2026-03-26)

Features

Bug Fixes

3.12.0 (2026-03-23)

Features

3.11.0 (2026-03-05)

Features

3.10.0 (2026-02-12)

... (truncated)

Commits
  • 905bfe3 chore: release main (#17832)
  • ad8f93c fix(api-core): use truthiness check in setup_request_id to support proto-plus...
  • 06dd2c0 chore: bump google-api-core to 2.28.0 (#18003)
  • 08f21a6 fix(proto-plus): add context to TypeErrors during message manipulation (#17682)
  • 32fd479 tests(spanner): avoid table name collisions in tests (#18001)
  • 2207ca6 feat: add pandas-gbq capability helper (#17957)
  • 6167e41 fix: deduplicate x-goog-api-client headers (#17616)
  • 71bc622 docs: add connector libraries overview table to package README (#17939)
  • 1b5c48b fix(bigframes): fix field name typos for ai.generate* functions (#17983)
  • f64ada2 fix: avoid retaining routing parameter instances in cache (#17961)
  • Additional commits viewable in compare view

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

Sourced from google-cloud-storage's releases.

google-cloud-storage: v3.13.1

3.13.1 (2026-08-06)

Bug Fixes

Changelog

Sourced from google-cloud-storage's changelog.

Changelog

PyPI History

3.15.0 (2026-06-02)

Features

3.14.0 (2026-04-02)

Features

Commits
  • 905bfe3 chore: release main (#17832)
  • ad8f93c fix(api-core): use truthiness check in setup_request_id to support proto-plus...
  • 06dd2c0 chore: bump google-api-core to 2.28.0 (#18003)
  • 08f21a6 fix(proto-plus): add context to TypeErrors during message manipulation (#17682)
  • 32fd479 tests(spanner): avoid table name collisions in tests (#18001)
  • 2207ca6 feat: add pandas-gbq capability helper (#17957)
  • 6167e41 fix: deduplicate x-goog-api-client headers (#17616)
  • 71bc622 docs: add connector libraries overview table to package README (#17939)
  • 1b5c48b fix(bigframes): fix field name typos for ai.generate* functions (#17983)
  • f64ada2 fix: avoid retaining routing parameter instances in cache (#17961)
  • Additional commits viewable in compare view

Updates `google-resumable-media` from 2.10.0 to 2.10.1
Release notes

Sourced from google-resumable-media's releases.

google-resumable-media: v2.10.1

2.10.1 (2026-08-06)

Bug Fixes

Changelog

Sourced from google-resumable-media's changelog.

Changelog

PyPI History

3.15.0 (2026-06-02)

Features

3.14.0 (2026-04-02)

Features

3.13.0 (2026-03-26)

Features

Bug Fixes

3.12.0 (2026-03-23)

Features

3.11.0 (2026-03-05)

Features

3.10.0 (2026-02-12)

... (truncated)

Commits
  • 905bfe3 chore: release main (#17832)
  • ad8f93c fix(api-core): use truthiness check in setup_request_id to support proto-plus...
  • 06dd2c0 chore: bump google-api-core to 2.28.0 (#18003)
  • 08f21a6 fix(proto-plus): add context to TypeErrors during message manipulation (#17682)
  • 32fd479 tests(spanner): avoid table name collisions in tests (#18001)
  • 2207ca6 feat: add pandas-gbq capability helper (#17957)
  • 6167e41 fix: deduplicate x-goog-api-client headers (#17616)
  • 71bc622 docs: add connector libraries overview table to package README (#17939)
  • 1b5c48b fix(bigframes): fix field name typos for ai.generate* functions (#17983)
  • f64ada2 fix: avoid retaining routing parameter instances in cache (#17961)
  • Additional commits viewable in compare view

Updates `googleapis-common-protos` from 1.75.0 to 1.75.1
Release notes

Sourced from googleapis-common-protos's releases.

googleapis-common-protos: v1.75.1

1.75.1 (2026-08-06)

Bug Fixes

Commits
  • 905bfe3 chore: release main (#17832)
  • ad8f93c fix(api-core): use truthiness check in setup_request_id to support proto-plus...
  • 06dd2c0 chore: bump google-api-core to 2.28.0 (#18003)
  • 08f21a6 fix(proto-plus): add context to TypeErrors during message manipulation (#17682)
  • 32fd479 tests(spanner): avoid table name collisions in tests (#18001)
  • 2207ca6 feat: add pandas-gbq capability helper (#17957)
  • 6167e41 fix: deduplicate x-goog-api-client headers (#17616)
  • 71bc622 docs: add connector libraries overview table to package README (#17939)
  • 1b5c48b fix(bigframes): fix field name typos for ai.generate* functions (#17983)
  • f64ada2 fix: avoid retaining routing parameter instances in cache (#17961)
  • Additional commits viewable in compare view

Updates `mako` from 1.3.12 to 1.4.1
Release notes

Sourced from mako's releases.

1.4.1

Released: Wed Aug 5 2026

bug

  • [bug] [installation] Fixed issue in the 1.4.0 packaging where the repository's internal tools/ directory was detected by setuptools package discovery and installed as a top-level tools package into site-packages, shadowing unrelated tools packages belonging to other applications. Package discovery is now limited to the mako package explicitly.

    References: #438

1.4.0

Released: Tue Aug 4 2026

changed

  • [changed] [examples] The examples/bench folder has been removed as it used mostly long-obsolete template engines. The examples/wsgi/run_wsgi.py example has been updated to remove the use of the removed-in-Python-3.13 cgi module, and to be runnable as a module from the project root.

  • [changed] [installation] Minimum MarkupSafe dependency version bumped from 0.9.2 to 2.0.

  • [changed] [tests] The test suite now runs via nox. The old tox.ini remains however nox will be the only system that's maintained.

  • [changed] [installation] Project metadata has been migrated to PEP 621 pyproject.toml-based configuration. setup.cfg remains only for the [mako_testing] section used by Mako's own test suite. The build requirements now set the minimum setuptools version at 77.0.0 in order to build Mako from source.

  • [changed] [installation] Minimum Python version is now 3.10. Mako 1.4.0 has been tested up through Python 3.15.0b4.

bug

  • [bug] [ext] The minimum Lingua version supported by LinguaMakoExtractor is now 4.16. The test suite had continued to pin Lingua below 4 long after the extractor itself was repaired to work with Lingua 4 in version 1.2.0, with the result that the plugin was no longer covered by tests at all; the pinned version additionally imports pkg_resources at startup, which is not present in current setuptools releases and left the package

... (truncated)

Commits

Updates `packaging` from 26.2 to 26.3
Release notes

Sourced from packaging's releases.

26.3

What's Changed

Features

  • Add a public VersionRange API and SpecifierSet.to_range(), representing the versions a specifier set accepts as an interval set that supports intersection, union, difference, complement, set relations, membership tests, and filtering. VersionRange.to_specifier_set() converts a range back to a SpecifierSet where a PEP 440 form exists. (#1267, #1270, #1298)
  • PEP 808: accept Metadata-Version: 2.6. (#1194)
  • Add a limit argument to parse_tag() for compressed tag sets. (#1220)
  • Add a prefer_sdist_predicate argument to Pylock.select() to prefer source distributions over wheels for selected packages. (#1334)
  • Add pure_python_tags() to generate the pure-Python tags for a Python version without touching the running platform. (#1346)
  • Add SpecifierSet.is_subset(), SpecifierSet.is_superset(), and SpecifierSet.is_disjoint(), which compare the versions two specifier sets accept. (#1313)

Behavior adaptations

  • Drop support for Python 3.8; packaging now requires Python 3.9 or later. (#1157)
  • Prefer native linux_* platform tags over manylinux and musllinux tags on Linux. (#160)

Fixes for versions and specifiers

  • Raise InvalidVersion instead of TypeError when Version is given a non-string. (#1319)
  • Raise InvalidVersion for non-string pre-release letters passed to Version.from_parts. (#1241)
  • Fix an AttributeError when hashing internally trimmed versions. (#1242)
  • Fix SpecifierSet.is_unsatisfiable for post-release boundary intersections. (#1257)

Fixes for requirements and markers

  • Make Requirement.__hash__ consistent with __eq__ for trailing-zero-equivalent specifiers (e.g. foo==1.0.0 and foo==1.0.0.0), so equal requirements hash equal and deduplicate in sets and dicts. (#1232)
  • Normalize requested extra names before comparing or hashing requirements. (#644)
  • Preserve a Requirement's specifier prereleases override across a pickle round trip. (#1204)
  • Raise InvalidRequirement instead of InvalidSpecifier when a requirement contains an invalid specifier. (#1332)
  • Clarify the error for post-release prefix wildcards like ==1.0.post1.*. (#1299)
  • Preserve quoting semantics when serializing marker values, so round-tripped markers parse back to the same marker. (#1213)
  • Keep the parentheses of a nested group when serializing markers. (#1316)
  • Normalize extra and dependency_groups values in nested markers at parse time. (#1246, #1310)
  • Raise UndefinedComparison when a set-valued variable like extras is used outside the membership form. (#1265)
  • Raise UndefinedEnvironmentName (a KeyError subclass) for missing environment keys during marker evaluation. (#1276)
  • Wrap malformed string literal errors in InvalidMarker / InvalidRequirement instead of leaking a low-level error. (#1249)
  • Reject requirements and markers with a trailing line break. (#1345)

Fixes for metadata and licenses

  • Collect all from_email validation errors into one ExceptionGroup instead of raising the first. (#1268)
  • Accept the UTF-8 charset case-insensitively in email payloads. (#1330)
  • Reject malformed Description-Content-Type values. (#1329)
  • Don't rewrite user values that contain {field} placeholders in error messages. (#1327)
  • Route multipart email payloads to unparsed instead of asserting. (#1247)
  • Make InvalidMetadata and CyclicDependencyGroup picklable. (#1328)
  • Fold every line boundary str.splitlines recognizes when writing a header with RFC822Message. (#1356)

... (truncated)

Changelog

Sourced from packaging's changelog.

26.3 - 2026-08-03


Features:
  • Add a public :class:~packaging.ranges.VersionRange API and
    :meth:SpecifierSet.to_range() &lt;packaging.specifiers.SpecifierSet.to_range&gt;,
    representing the versions a specifier set accepts as an interval set that
    supports intersection, union, difference, complement, set relations,
    membership tests, and filtering.
    :meth:~packaging.ranges.VersionRange.to_specifier_set converts a range back
    to a :class:~packaging.specifiers.SpecifierSet where a PEP 440 form exists.
    (:pull:1267, :pull:1270, :pull:1298)
  • PEP 808: accept Metadata-Version: 2.6. (:pull:1194)
  • Add a limit argument to parse_tag() for compressed tag sets.
    (:issue:1220)
  • Add a prefer_sdist_predicate argument to Pylock.select() to prefer
    source distributions over wheels for selected packages. (:pull:1334)
  • Add :func:~packaging.tags.pure_python_tags to generate the pure-Python
    tags for a Python version without touching the running platform.
    (:pull:1346)
  • Add :meth:SpecifierSet.is_subset() &lt;packaging.specifiers.SpecifierSet.is_subset&gt;, :meth:~packaging.specifiers.SpecifierSet.is_superset,
    and :meth:~packaging.specifiers.SpecifierSet.is_disjoint, which compare the
    versions two specifier sets accept. (:pull:1313)

Behavior adaptations:

  • Drop support for Python 3.8; packaging now requires Python 3.9 or later.
    (:pull:1157)
  • Prefer native linux_* platform tags over manylinux and musllinux
    tags on Linux. (:issue:160)

Fixes for versions and specifiers:

  • Raise InvalidVersion instead of TypeError when Version is given a
    non-string. (:pull:1319)
  • Raise InvalidVersion for non-string pre-release letters passed to
    Version.from_parts. (:pull:1241)
  • Fix an AttributeError when hashing internally trimmed versions.
    (:pull:1242)
  • Fix SpecifierSet.is_unsatisfiable for post-release boundary
    intersections. (:pull:1257)

Fixes for requirements and markers:

  • Make Requirement.__hash__ consistent with __eq__ for
    trailing-zero-equivalent specifiers (e.g. foo==1.0.0 and
    foo==1.0.0.0), so equal requirements hash equal and deduplicate in
    sets and dicts. (:pull:1232)
    </tr></table>

... (truncated)

Commits
  • 929fd4b Bump for release
  • f300ebf chore(deps): bump the pre-commit group with 5 updates (#1357)
  • f91d975 ci(downstream): bump hatchling to 1.31.0 and fix its pytest rootdir (#1361)
  • b1a7124 chore(deps): bump the github-actions group with 7 updates (#1358)
  • 2d873eb fix(metadata): fold every line boundary when writing headers (#1356)
  • 413d006 docs: changelog for 26.3 (#1343)
  • 4eb0753 docs(metadata): explain selective field validation (#1342)
  • 77e9ed4 feat(tags): add pure Python tag generator (#1346)
  • 7cea5e8 ci: drop 3.13t on Windows (3.13.14t may fail to build, run takes 9 minutes) (...
  • 45a8b34 docs: add missing versionadded/versionchanged directives (=2.9.12", "pyasn1==0.6.4", @@ -71,7 +71,7 @@ dependencies = [ "pydantic-core==2.41.5", "pygments==2.20.0", "pyjwt==2.13.0", - "pygeoapi==0.23.5", + "pygeoapi==0.24.0", "pyproj==3.7.2", "pyshp==2.3.1", "python-dateutil==2.9.0.post0", @@ -80,7 +80,7 @@ dependencies = [ "pytz==2026.3.post1", "requests==2.34.2", "rsa==4.9.1", - "scramp==1.4.15", + "scramp==1.4.16", "sentry-sdk[fastapi]==2.66.1", "shapely==2.1.2", "six==1.17.0", @@ -90,14 +90,14 @@ dependencies = [ "sqlalchemy-searchable==2.1.0", "sqlalchemy-utils==0.42.1", "sqlparse>=0.5.5", - "starlette==1.3.1", - "typer==0.27.0", + "starlette==1.4.1", + "typer==0.27.1", "typing-extensions==4.16.0", "typing-inspection==0.4.2", "tzdata==2026.3", "urllib3==2.7.0", - "utm==0.8.1", - "uvicorn==0.51.0", + "utm==0.9.0", + "uvicorn==0.52.1", "yarl==1.24.5", "pymssql>=2.3.13", ] diff --git a/requirements.txt b/requirements.txt index 2f1437493..d7f6222ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -149,13 +149,13 @@ aiosqlite==0.22.1 \ --hash=sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650 \ --hash=sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb # via ocotilloapi -alembic==1.18.5 \ - --hash=sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc \ - --hash=sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e +alembic==1.19.0 \ + --hash=sha256:6487c612fc719dcfa22b17d2dd5b2b458929641e6aa2f0b65b135727f5e6d501 \ + --hash=sha256:cf839d3849116aab3cc047e09c6968b9bd6b2fde61b6bb7c1e97352fe5503580 # via ocotilloapi -annotated-doc==0.0.4 \ - --hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \ - --hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4 +annotated-doc==0.0.5 \ + --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ + --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb # via # fastapi # typer @@ -285,9 +285,9 @@ blinker==1.9.0 \ --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc # via flask -cachetools==7.1.6 \ - --hash=sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096 \ - --hash=sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1 +cachetools==7.1.7 \ + --hash=sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50 \ + --hash=sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0 # via ocotilloapi certifi==2026.7.22 \ --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ @@ -300,107 +300,107 @@ certifi==2026.7.22 \ # rasterio # requests # sentry-sdk -cffi==2.1.0 \ - --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \ - --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \ - --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \ - --hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \ - --hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \ - --hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \ - --hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \ - --hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \ - --hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \ - --hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \ - --hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \ - --hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \ - --hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \ - --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \ - --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \ - --hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \ - --hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \ - --hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \ - --hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \ - --hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \ - --hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \ - --hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \ - --hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \ - --hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \ - --hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \ - --hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \ - --hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \ - --hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \ - --hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \ - --hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \ - --hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \ - --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \ - --hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \ - --hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \ - --hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \ - --hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \ - --hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \ - --hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \ - --hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \ - --hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \ - --hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \ - --hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \ - --hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \ - --hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \ - --hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \ - --hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \ - --hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \ - --hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \ - --hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \ - --hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \ - --hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \ - --hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \ - --hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \ - --hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \ - --hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \ - --hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \ - --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \ - --hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \ - --hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \ - --hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \ - --hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \ - --hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \ - --hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \ - --hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \ - --hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \ - --hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \ - --hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \ - --hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \ - --hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \ - --hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \ - --hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \ - --hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \ - --hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \ - --hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \ - --hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \ - --hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \ - --hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \ - --hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \ - --hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \ - --hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \ - --hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \ - --hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \ - --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \ - --hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \ - --hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \ - --hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \ - --hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \ - --hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \ - --hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \ - --hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \ - --hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \ - --hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \ - --hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \ - --hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \ - --hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \ - --hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \ - --hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \ - --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \ - --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \ - --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 # via # cryptography # ocotilloapi @@ -579,9 +579,9 @@ cryptography==50.0.0 \ # google-auth # joserfc # ocotilloapi -dateparser==1.4.1 \ - --hash=sha256:f25d4e051a84be27a35bd297e3e1dc59ff78373701b89be352ba80372d22d0d0 \ - --hash=sha256:f265df13c0380e2e07543ba74b67c0681aaa1096981ffcd35227e1aa0cb81c7c +dateparser==1.4.2 \ + --hash=sha256:752f3d49d477cf7f60a7a9c8bcb19c882496ede0e377d5a3d80014cdfeca7050 \ + --hash=sha256:bed2a3fd9bad8f2fb2d72b57748bada260b3a9349a264c22ffc23c3249d7049a # via pygeofilter dnspython==2.8.0 \ --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \ @@ -601,21 +601,21 @@ email-validator==2.3.0 \ --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 # via ocotilloapi -fastapi==0.140.2 \ - --hash=sha256:5f64faeb12339d783510db0498da660539195ac7b5bdf69c0fc558f4580a9724 \ - --hash=sha256:944336ef298148dfd97478638567b223d929aba8717ad8be73ae962a62ecd61d +fastapi==0.141.1 \ + --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \ + --hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1 # via # apitally # fastapi-pagination # ocotilloapi # sentry-sdk -fastapi-pagination==0.15.15 \ - --hash=sha256:d6e9e4bc4d6e20709dcabc11b16056cd5cd184c995ee214b0190f6b81426fa0c \ - --hash=sha256:dc828d7cd15614c650c284bd2c3a98a8a2d9ce340508be3970dc8986908a02aa +fastapi-pagination==0.15.16 \ + --hash=sha256:739a4e904729dc01e03ce95e260ed9be050d48a3659be8463e5c4fcdd0cf25b0 \ + --hash=sha256:86dc73620812d47c297a7b8baca4eba2bac5d3f1d73aa75dc6e3bb12c0b803f7 # via ocotilloapi -filelock==3.32.0 \ - --hash=sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402 \ - --hash=sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3 +filelock==3.32.2 \ + --hash=sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82 \ + --hash=sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8 # via pygeoapi flask==3.1.3 \ --hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \ @@ -696,31 +696,31 @@ geoalchemy2==0.20.0 \ --hash=sha256:1489a1d106519542a79c97cd0b4c537d80462c353610ebc2429cf2c43daac717 \ --hash=sha256:450f427f4bc3cf2d5ddee0af3763aed0f3eea2384e7c9a99798d8f1508279322 # via ocotilloapi -google-api-core==2.33.0 \ - --hash=sha256:3a36bcc3e319783f4c97da41f6f45ea6ffcaa55848e341de16e09cb70243c2bb \ - --hash=sha256:a2e22a0c1d0f03eafff1858b38cf46f832d5902b0c052235bf0ab8402929fbdc +google-api-core==2.34.0 \ + --hash=sha256:98a779fe72de956eb1c9c2f47ff4c4432a668ece1a002ec38bed07ec2698ae59 \ + --hash=sha256:cdf9c67e7ca2402d86ccbfde5f2503fc83e3cc3f58cc78456ae96cad24a6d2de # via # google-cloud-core # google-cloud-storage # ocotilloapi -google-auth==2.56.2 \ - --hash=sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6 \ - --hash=sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051 +google-auth==2.56.3 \ + --hash=sha256:40e229fc901f0a305b553050e5fce562d509bee0435be053abfa91582b51b90c \ + --hash=sha256:8ec438808f813ad034535000261eed1067475d229d05bbf4216e78c3f2362e53 # via # cloud-sql-python-connector # google-api-core # google-cloud-core # google-cloud-storage # ocotilloapi -google-cloud-core==2.6.0 \ - --hash=sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e \ - --hash=sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83 +google-cloud-core==2.6.1 \ + --hash=sha256:1e044b131f2ae097b92312fa195164b0aeb6dc6a88e00231e1210516314c420c \ + --hash=sha256:2682a8a4474a32f56292fb4bca7fa7e4fb0b4af958f6abfe4bca8d195747fd45 # via # google-cloud-storage # ocotilloapi -google-cloud-storage==3.13.0 \ - --hash=sha256:648af3ef8a6acc674e1359d3c920c67eb89a7a5ab66b336bd3ac43fed6b5ab84 \ - --hash=sha256:d11d8706ea1520fba0f21043bcb7897caf7015d76ce1ad9a4f60237e4d7a9f6c +google-cloud-storage==3.13.1 \ + --hash=sha256:98208de6c21e85cecd3eb44551894efff33d98365500e178867d4305854a770a \ + --hash=sha256:a80bf8cac2794808aa61c50c5f769ecbbe2d10331bacd0d69d30e59b14b346b2 # via ocotilloapi google-crc32c==1.8.0 \ --hash=sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa \ @@ -738,15 +738,15 @@ google-crc32c==1.8.0 \ # google-cloud-storage # google-resumable-media # ocotilloapi -google-resumable-media==2.10.0 \ - --hash=sha256:88152884bee37b2bf36a0ab81ad8c7fd12212c9803dd981d77c1b35b02d34e7c \ - --hash=sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee +google-resumable-media==2.10.1 \ + --hash=sha256:224975032ddb73f7ed9e2f0f4cc08ed1b06874c52d48cc8533e3eb72980b21a0 \ + --hash=sha256:4e2cbc704207ddc09f23b1f18e8ef4a4ccbfe0f1768b370e5c969704adbd0a1c # via # google-cloud-storage # ocotilloapi -googleapis-common-protos==1.75.0 \ - --hash=sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd \ - --hash=sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed +googleapis-common-protos==1.75.1 \ + --hash=sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79 \ + --hash=sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071 # via # google-api-core # ocotilloapi @@ -904,9 +904,9 @@ lark==1.3.1 \ --hash=sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905 \ --hash=sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12 # via pygeofilter -mako==1.3.12 \ - --hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \ - --hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a +mako==1.4.1 \ + --hash=sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617 \ + --hash=sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27 # via # alembic # ocotilloapi @@ -1114,9 +1114,9 @@ opentelemetry-semantic-conventions==0.65b0 \ --hash=sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb \ --hash=sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60 # via opentelemetry-sdk -packaging==26.2 \ - --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ - --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c # via # geoalchemy2 # gunicorn @@ -1145,9 +1145,9 @@ pg8000==1.31.5 \ --hash=sha256:0af2c1926b153307639868d2ee5cef6cd3a7d07448e12736989b10e1d491e201 \ --hash=sha256:46ebb03be52b7a77c03c725c79da2ca281d6e8f59577ca66b17c9009618cae78 # via ocotilloapi -phonenumbers==9.0.35 \ - --hash=sha256:57ef9787ddf2bc8cc0906d5c876d43fcd65fa25e7330d00b9f2ba8528870b72a \ - --hash=sha256:b19d97e8c448ccfd8d646888d2a65635372f75b9916006cc0a2f809b531baaea +phonenumbers==9.0.36 \ + --hash=sha256:60ca2a6870d2532d6e960105790e85e9d69270ede81746d10cf57f5e437b93ec \ + --hash=sha256:c16a5b95178345ec2df1954578a4ac09e937443b2ee992dd7b15a8e8c81f4acf # via ocotilloapi pillow==12.3.0 \ --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ @@ -1317,9 +1317,9 @@ propcache==0.5.2 \ # aiohttp # ocotilloapi # yarl -proto-plus==1.28.2 \ - --hash=sha256:26d843eb99c1e32fdf1d20ff0faae56607f7748fe774acf9ecd5cfe6c6472501 \ - --hash=sha256:b874236fcac2358f601e4330bcb76cb8b89c851303ccf4078408b3d4774d1c52 +proto-plus==1.28.3 \ + --hash=sha256:5f91b30dafa6bb38d432c5557a6ee1d35ffd40b4b1e0e3ca27260448560b91d9 \ + --hash=sha256:dc76880b8ee951cca002098574376cf71e055f9f16d9ba6570fb8a06f726d281 # via # google-api-core # ocotilloapi @@ -1460,9 +1460,9 @@ pydantic-core==2.41.5 \ # via # ocotilloapi # pydantic -pygeoapi==0.23.5 \ - --hash=sha256:66ec6c466f00a2ec4af77b886bc4b046d70a2fde4cdf21021e551231db955e19 \ - --hash=sha256:9f1456738a8851c582f7336159dfaaa96ca9704b9c554c0cc59d699042314359 +pygeoapi==0.24.0 \ + --hash=sha256:00cf5c88776284780bb8bcc82f4f48c52d25ddfefc9ba1a65a8f3f811670a0a3 \ + --hash=sha256:0ca8063121c19b86c90e896ed72758ce942be6c4a232062cc919ff7a8af36435 # via ocotilloapi pygeofilter==0.4.0 \ --hash=sha256:cbb4a5f14af0b87e4f0c0c81c659ff64e44351c98e9f61d36af515d896fa8a05 \ @@ -1887,9 +1887,9 @@ rsa==4.9.1 \ # via # ocotilloapi # python-jose -scramp==1.4.15 \ - --hash=sha256:9d6102948d9005e3802384a328429dfd67d691a65791007c354ff89895857396 \ - --hash=sha256:d25cdd3dbc493773647bccb93e4e85bbff0c091141ec8ff8d61bad1e3638082d +scramp==1.4.16 \ + --hash=sha256:836bcecdc8843af76b35e11adbe818e3d7934d0d25aa48fd2e8cde362a2c6a9b \ + --hash=sha256:9e6148fca008ab6c6ce84658aeefd03d5d7f269d32fe08d07e134dae089e937a # via # ocotilloapi # pg8000 @@ -1999,9 +1999,9 @@ sqlparse==0.5.5 \ --hash=sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba \ --hash=sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e # via ocotilloapi -starlette==1.3.1 \ - --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \ - --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6 +starlette==1.4.1 \ + --hash=sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19 \ + --hash=sha256:b7332de6e9375593a29ba9eee1e6ecfeb3eb2043e2e19a13b4b71da73ff35540 # via # apitally # fastapi @@ -2011,13 +2011,13 @@ starlette-admin==0.17.1 \ --hash=sha256:685615945d55de636879e3523ec70a6419176f7cd6f04a17470e35670f96b972 \ --hash=sha256:7bdeaf1c30fd9036ef3779fb0255002d3d18aaf6f7e674200e21c604ee563fc7 # via ocotilloapi -tinydb==4.8.2 \ - --hash=sha256:f7dfc39b8d7fda7a1ca62a8dbb449ffd340a117c1206b68c50b1a481fb95181d \ - --hash=sha256:f97030ee5cbc91eeadd1d7af07ab0e48ceb04aa63d4a983adbaca4cba16e86c3 +tinydb==4.9.0 \ + --hash=sha256:111f1f680978a1b7c534d698dd1d76739ff31c74c27fd26a111140b51d8b35d6 \ + --hash=sha256:6928b1fa785186bda7952a0ba05aaeedc883ede565ca9c7d608de44e5e75de70 # via pygeoapi -typer==0.27.0 \ - --hash=sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5 \ - --hash=sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1 +typer==0.27.1 \ + --hash=sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56 \ + --hash=sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df # via ocotilloapi types-pytz==2025.2.0.20250809 \ --hash=sha256:222e32e6a29bb28871f8834e8785e3801f2dc4441c715cd2082b271eecbe21e5 \ @@ -2064,13 +2064,13 @@ urllib3==2.7.0 \ # ocotilloapi # requests # sentry-sdk -utm==0.8.1 \ - --hash=sha256:634d5b6221570ddc6a1e94afa5c51bae92bcead811ddc5c9bc0a20b847c2dafa \ - --hash=sha256:e3d5e224082af138e40851dcaad08d7f99da1cc4b5c413a7de34eabee35f434a +utm==0.9.0 \ + --hash=sha256:1c8ffa6032631379374ceef05e6fea0ad42e9f09be0c3f91f7cc1b23f27be8a7 \ + --hash=sha256:767592281e457dfacd71323ac69ff38e2f290d74526af91ca0924637de0e1d53 # via ocotilloapi -uvicorn==0.51.0 \ - --hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b \ - --hash=sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0 +uvicorn==0.52.1 \ + --hash=sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd \ + --hash=sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a # via ocotilloapi werkzeug==3.1.8 \ --hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \ diff --git a/uv.lock b/uv.lock index 6cecc7ccb..a9f1d2cb0 100644 --- a/uv.lock +++ b/uv.lock @@ -137,16 +137,16 @@ wheels = [ [[package]] name = "alembic" -version = "1.18.5" +version = "1.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/01/a48dab7827ac4421272399f7ed9a2ec17edd12c8bcde4417bd7b6821b71a/alembic-1.19.0.tar.gz", hash = "sha256:6487c612fc719dcfa22b17d2dd5b2b458929641e6aa2f0b65b135727f5e6d501", size = 2069906, upload-time = "2026-08-04T18:57:04.599Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, + { url = "https://files.pythonhosted.org/packages/34/79/59ab6f1fe72eee229de91aa393225389a85df12a0fbbbfa264efcf6d7872/alembic-1.19.0-py3-none-any.whl", hash = "sha256:cf839d3849116aab3cc047e09c6968b9bd6b2fde61b6bb7c1e97352fe5503580", size = 265738, upload-time = "2026-08-04T18:57:06.219Z" }, ] [[package]] @@ -395,11 +395,11 @@ wheels = [ [[package]] name = "cachetools" -version = "7.1.6" +version = "7.1.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/af/861ebc2e318a5c3300e3eb63bc4d30f3d70a46d13b360093728ac0705eed/cachetools-7.1.6.tar.gz", hash = "sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1", size = 40572, upload-time = "2026-07-23T22:47:53.737Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/d2/47e8bc06fe2a06d3f5bdf20f1126ab66c4e99dc48d940e7ba873f7ac7131/cachetools-7.1.7.tar.gz", hash = "sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50", size = 40680, upload-time = "2026-08-01T21:20:40.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/f2/2086ba18a925a73586c4d4e61d25f4a6058e56fd00d77ce8f1d361ab4c9b/cachetools-7.1.6-py3-none-any.whl", hash = "sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096", size = 16954, upload-time = "2026-07-23T22:47:52.397Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d8/767faeda872075724b95dd675466a645f1b92aadcdcf2d1429dcfd76c176/cachetools-7.1.7-py3-none-any.whl", hash = "sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0", size = 16830, upload-time = "2026-08-01T21:20:38.977Z" }, ] [[package]] @@ -413,75 +413,75 @@ wheels = [ [[package]] name = "cffi" -version = "2.1.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, - { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, - { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, - { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, - { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, - { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, - { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, - { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, - { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, - { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, - { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, - { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, - { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, - { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, - { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, - { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, - { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, - { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, - { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, - { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, - { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, - { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, - { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, - { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, - { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, - { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, - { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, - { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, - { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, - { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, - { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, - { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, ] [[package]] @@ -806,7 +806,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.140.2" +version = "0.141.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -815,23 +815,23 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/fa/67d7232a733c5f5cea8859dcd6f6de78a688bb8886676808e3f5a8358008/fastapi-0.140.2.tar.gz", hash = "sha256:5f64faeb12339d783510db0498da660539195ac7b5bdf69c0fc558f4580a9724", size = 421280, upload-time = "2026-07-27T14:15:39.682Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/ed/15196be41f2bf84e358d899e62daf5666ba4f6dbab4cbad87d16cc16df6d/fastapi-0.140.2-py3-none-any.whl", hash = "sha256:944336ef298148dfd97478638567b223d929aba8717ad8be73ae962a62ecd61d", size = 130883, upload-time = "2026-07-27T14:15:38.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] [[package]] name = "fastapi-pagination" -version = "0.15.15" +version = "0.15.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi" }, { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/79/826b79dd718b0d9a91f21c3cfed365e5ecf44bc31419615c4cebeebb9ada/fastapi_pagination-0.15.15.tar.gz", hash = "sha256:d6e9e4bc4d6e20709dcabc11b16056cd5cd184c995ee214b0190f6b81426fa0c", size = 614873, upload-time = "2026-06-16T17:25:44.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/65/9c2f1c324fa7a83b328b024c45e70471007b165a1d56ab237fe0e574e36f/fastapi_pagination-0.15.16.tar.gz", hash = "sha256:739a4e904729dc01e03ce95e260ed9be050d48a3659be8463e5c4fcdd0cf25b0", size = 614997, upload-time = "2026-07-28T18:41:07.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/02/2e794ea498515afdadf2891f89aaed404de3ed619dd9cbf1ca637f17df19/fastapi_pagination-0.15.15-py3-none-any.whl", hash = "sha256:dc828d7cd15614c650c284bd2c3a98a8a2d9ce340508be3970dc8986908a02aa", size = 65864, upload-time = "2026-06-16T17:25:42.734Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d1/9725ec62421dcfe430d874cba9f82c0288baee0c4b7dd8aa8dcca2b0394e/fastapi_pagination-0.15.16-py3-none-any.whl", hash = "sha256:86dc73620812d47c297a7b8baca4eba2bac5d3f1d73aa75dc6e3bb12c0b803f7", size = 65991, upload-time = "2026-07-28T18:41:06.076Z" }, ] [[package]] @@ -962,7 +962,7 @@ wheels = [ [[package]] name = "google-api-core" -version = "2.33.0" +version = "2.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, @@ -971,9 +971,9 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/62/8fb1fb647d2788c950d69d6a769cd9d55c918ac1fc57be2f90b7e4029787/google_api_core-2.33.0.tar.gz", hash = "sha256:3a36bcc3e319783f4c97da41f6f45ea6ffcaa55848e341de16e09cb70243c2bb", size = 181607, upload-time = "2026-07-22T16:28:28.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7c/9be3903e3d45415e8ca493c75f8990a0f6f579d168015d44c379350d0ab0/google_api_core-2.34.0.tar.gz", hash = "sha256:98a779fe72de956eb1c9c2f47ff4c4432a668ece1a002ec38bed07ec2698ae59", size = 187953, upload-time = "2026-08-06T06:23:58.128Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/31/5056a347bb934ea04583c8b27916ef1501729c72638629545bce26ff4223/google_api_core-2.33.0-py3-none-any.whl", hash = "sha256:a2e22a0c1d0f03eafff1858b38cf46f832d5902b0c052235bf0ab8402929fbdc", size = 176462, upload-time = "2026-07-22T16:28:22.447Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c1/a8a92ae1bc4b1a8f804c776d7d3f0c771b78a62c3ad4df1be41b3fd8c767/google_api_core-2.34.0-py3-none-any.whl", hash = "sha256:cdf9c67e7ca2402d86ccbfde5f2503fc83e3cc3f58cc78456ae96cad24a6d2de", size = 180545, upload-time = "2026-08-06T06:22:47.502Z" }, ] [[package]] @@ -994,15 +994,15 @@ wheels = [ [[package]] name = "google-auth" -version = "2.56.2" +version = "2.56.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/33/dbc946a407401b975f0719658f18e664ece2109f79ffd1ff3bf226c205f4/google_auth-2.56.2.tar.gz", hash = "sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051", size = 365820, upload-time = "2026-07-21T21:53:28.188Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/4c/fa42116a48bab3f7a143cf5042ecff7df9c8b73f8a376203cd534d1dc966/google_auth-2.56.3.tar.gz", hash = "sha256:40e229fc901f0a305b553050e5fce562d509bee0435be053abfa91582b51b90c", size = 367110, upload-time = "2026-08-06T06:24:01.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/63/50636aae68c9bf17c891c7eb18b49baa9bd6b31d2a97b8de4813a9fc8d1c/google_auth-2.56.2-py3-none-any.whl", hash = "sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6", size = 258588, upload-time = "2026-07-21T21:53:26.399Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b3/6117b2f24065cd7e2c4f140e9a193e215f089ca8ba314cf91eb9d0b7fe0a/google_auth-2.56.3-py3-none-any.whl", hash = "sha256:8ec438808f813ad034535000261eed1067475d229d05bbf4216e78c3f2362e53", size = 259116, upload-time = "2026-08-06T06:22:51.788Z" }, ] [[package]] @@ -1020,20 +1020,20 @@ wheels = [ [[package]] name = "google-cloud-core" -version = "2.6.0" +version = "2.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, { name = "google-auth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/c6/9d7d9ed6703eb35306ca7bf381fb66ba8d978c61a1b550a2e1730a4c4ce8/google_cloud_core-2.6.1.tar.gz", hash = "sha256:1e044b131f2ae097b92312fa195164b0aeb6dc6a88e00231e1210516314c420c", size = 36017, upload-time = "2026-08-06T06:24:18.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/4a/98da8930ab109c73d9a5d13782a9ebb81ea8c111f6d534a567b71d23e52b/google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e", size = 29390, upload-time = "2026-05-07T08:02:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/5d/4f/37960d5255988b218c40c9b5a2b731013684294a50735d1dd1ab4894e531/google_cloud_core-2.6.1-py3-none-any.whl", hash = "sha256:2682a8a4474a32f56292fb4bca7fa7e4fb0b4af958f6abfe4bca8d195747fd45", size = 29393, upload-time = "2026-08-06T06:23:11.405Z" }, ] [[package]] name = "google-cloud-storage" -version = "3.13.0" +version = "3.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -1043,9 +1043,9 @@ dependencies = [ { name = "google-resumable-media" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/25/355ed97c1723c787dfaa888808d55db18371f82c38ff862357b1e902cd19/google_cloud_storage-3.13.0.tar.gz", hash = "sha256:d11d8706ea1520fba0f21043bcb7897caf7015d76ce1ad9a4f60237e4d7a9f6c", size = 17340960, upload-time = "2026-07-13T19:10:07.524Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/7e/73bb7512df1d1aad6ce3f9aed847cd40e0cd400ba4a85d86ab8eb412e9cc/google_cloud_storage-3.13.1.tar.gz", hash = "sha256:a80bf8cac2794808aa61c50c5f769ecbbe2d10331bacd0d69d30e59b14b346b2", size = 17341051, upload-time = "2026-08-06T06:24:42.229Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/e8/b3678a0931ee7d4b3fdaf0813e6206d66e0922b2c26d912f308728b5b95a/google_cloud_storage-3.13.0-py3-none-any.whl", hash = "sha256:648af3ef8a6acc674e1359d3c920c67eb89a7a5ab66b336bd3ac43fed6b5ab84", size = 341428, upload-time = "2026-07-13T19:09:52.39Z" }, + { url = "https://files.pythonhosted.org/packages/06/6f/d69f0e185e08ddb58c323a0a935af2b492907b5de362bc08933b0a3b5644/google_cloud_storage-3.13.1-py3-none-any.whl", hash = "sha256:98208de6c21e85cecd3eb44551894efff33d98365500e178867d4305854a770a", size = 341486, upload-time = "2026-08-06T06:23:36.548Z" }, ] [[package]] @@ -1068,26 +1068,26 @@ wheels = [ [[package]] name = "google-resumable-media" -version = "2.10.0" +version = "2.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-crc32c" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/f8/1ca5781d6be9cb9f73f7d40f4958c4bd1226a60598e3e39e1d6aaf838c4b/google_resumable_media-2.10.0.tar.gz", hash = "sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee", size = 2164570, upload-time = "2026-06-03T16:14:26.103Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/f5/f35505e6091614e285056a495488cb0a9c1a9dcc88a4a3c91bbc5fd4835b/google_resumable_media-2.10.1.tar.gz", hash = "sha256:224975032ddb73f7ed9e2f0f4cc08ed1b06874c52d48cc8533e3eb72980b21a0", size = 2164548, upload-time = "2026-08-06T06:24:50.489Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/d8/00c6854ac1512bb9eaf13bd3f8f28222f7674947fc510a4ff7616f2efc80/google_resumable_media-2.10.0-py3-none-any.whl", hash = "sha256:88152884bee37b2bf36a0ab81ad8c7fd12212c9803dd981d77c1b35b02d34e7c", size = 81533, upload-time = "2026-06-03T16:13:12.51Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ba/77ef49baf338c03a11deadac984e3161b3f2b4fa4bb5aab160e7ca0fd522/google_resumable_media-2.10.1-py3-none-any.whl", hash = "sha256:4e2cbc704207ddc09f23b1f18e8ef4a4ccbfe0f1768b370e5c969704adbd0a1c", size = 81533, upload-time = "2026-08-06T06:23:45.464Z" }, ] [[package]] name = "googleapis-common-protos" -version = "1.75.0" +version = "1.75.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/73/74bcab964c9a7a61f2bb71e8179b0f13e6fa98f7ce00fd168aab291e4a2e/googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071", size = 150967, upload-time = "2026-08-06T06:24:51.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/9a/51/186c02b8549b69ccda44429cf6ff5081e4b61a602ddfe6a8020d1be31d1b/googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79", size = 300626, upload-time = "2026-08-06T06:23:46.696Z" }, ] [[package]] @@ -1349,14 +1349,14 @@ wheels = [ [[package]] name = "mako" -version = "1.3.12" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, ] [[package]] @@ -1707,7 +1707,7 @@ requires-dist = [ { name = "aiohttp", specifier = "==3.14.3" }, { name = "aiosignal", specifier = "==1.4.0" }, { name = "aiosqlite", specifier = "==0.22.1" }, - { name = "alembic", specifier = "==1.18.5" }, + { name = "alembic", specifier = "==1.19.0" }, { name = "annotated-types", specifier = "==0.8.0" }, { name = "anyio", specifier = "==4.14.2" }, { name = "apitally", extras = ["fastapi"], specifier = "==0.25.1" }, @@ -1717,9 +1717,9 @@ requires-dist = [ { name = "attrs", specifier = "==26.1.0" }, { name = "authlib", specifier = "==1.7.2" }, { name = "bcrypt", specifier = "==4.3.0" }, - { name = "cachetools", specifier = "==7.1.6" }, + { name = "cachetools", specifier = "==7.1.7" }, { name = "certifi", specifier = "==2026.7.22" }, - { name = "cffi", specifier = "==2.1.0" }, + { name = "cffi", specifier = "==2.1.1" }, { name = "charset-normalizer", specifier = "==3.4.9" }, { name = "click", specifier = "==8.4.2" }, { name = "cloud-sql-python-connector", specifier = "==1.21.0" }, @@ -1727,17 +1727,17 @@ requires-dist = [ { name = "dnspython", specifier = "==2.8.0" }, { name = "dotenv", specifier = "==0.9.9" }, { name = "email-validator", specifier = "==2.3.0" }, - { name = "fastapi", specifier = "==0.140.2" }, - { name = "fastapi-pagination", specifier = "==0.15.15" }, + { name = "fastapi", specifier = "==0.141.1" }, + { name = "fastapi-pagination", specifier = "==0.15.16" }, { name = "frozenlist", specifier = "==1.8.0" }, { name = "geoalchemy2", specifier = "==0.20.0" }, - { name = "google-api-core", specifier = "==2.33.0" }, - { name = "google-auth", specifier = "==2.56.2" }, - { name = "google-cloud-core", specifier = "==2.6.0" }, - { name = "google-cloud-storage", specifier = "==3.13.0" }, + { name = "google-api-core", specifier = "==2.34.0" }, + { name = "google-auth", specifier = "==2.56.3" }, + { name = "google-cloud-core", specifier = "==2.6.1" }, + { name = "google-cloud-storage", specifier = "==3.13.1" }, { name = "google-crc32c", specifier = "==1.8.0" }, - { name = "google-resumable-media", specifier = "==2.10.0" }, - { name = "googleapis-common-protos", specifier = "==1.75.0" }, + { name = "google-resumable-media", specifier = "==2.10.1" }, + { name = "googleapis-common-protos", specifier = "==1.75.1" }, { name = "greenlet", specifier = "==3.5.4" }, { name = "gunicorn", specifier = "==23.0.0" }, { name = "h11", specifier = "==0.16.0" }, @@ -1746,19 +1746,19 @@ requires-dist = [ { name = "idna", specifier = "==3.18" }, { name = "iniconfig", specifier = "==2.3.0" }, { name = "jinja2", specifier = "==3.1.6" }, - { name = "mako", specifier = "==1.3.12" }, + { name = "mako", specifier = "==1.4.1" }, { name = "markupsafe", specifier = "==3.0.3" }, { name = "multidict", specifier = "==6.7.1" }, { name = "numpy", specifier = "==2.5.1" }, - { name = "packaging", specifier = "==26.2" }, + { name = "packaging", specifier = "==26.3" }, { name = "pandas", specifier = "==2.3.2" }, { name = "pandas-stubs", specifier = "~=2.3.2" }, { name = "pg8000", specifier = "==1.31.5" }, - { name = "phonenumbers", specifier = "==9.0.35" }, + { name = "phonenumbers", specifier = "==9.0.36" }, { name = "pillow", specifier = "==12.3.0" }, { name = "pluggy", specifier = "==1.6.0" }, { name = "propcache", specifier = "==0.5.2" }, - { name = "proto-plus", specifier = "==1.28.2" }, + { name = "proto-plus", specifier = "==1.28.3" }, { name = "protobuf", specifier = "==6.33.5" }, { name = "psycopg2-binary", specifier = ">=2.9.12" }, { name = "pyasn1", specifier = "==0.6.4" }, @@ -1766,7 +1766,7 @@ requires-dist = [ { name = "pycparser", specifier = "==3.0" }, { name = "pydantic", specifier = "==2.12.5" }, { name = "pydantic-core", specifier = "==2.41.5" }, - { name = "pygeoapi", specifier = "==0.23.5" }, + { name = "pygeoapi", specifier = "==0.24.0" }, { name = "pygments", specifier = "==2.20.0" }, { name = "pyjwt", specifier = "==2.13.0" }, { name = "pymssql", specifier = ">=2.3.13" }, @@ -1778,7 +1778,7 @@ requires-dist = [ { name = "pytz", specifier = "==2026.3.post1" }, { name = "requests", specifier = "==2.34.2" }, { name = "rsa", specifier = "==4.9.1" }, - { name = "scramp", specifier = "==1.4.15" }, + { name = "scramp", specifier = "==1.4.16" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.66.1" }, { name = "shapely", specifier = "==2.1.2" }, { name = "six", specifier = "==1.17.0" }, @@ -1788,14 +1788,14 @@ requires-dist = [ { name = "sqlalchemy-searchable", specifier = "==2.1.0" }, { name = "sqlalchemy-utils", specifier = "==0.42.1" }, { name = "sqlparse", specifier = ">=0.5.5" }, - { name = "starlette", specifier = "==1.3.1" }, - { name = "typer", specifier = "==0.27.0" }, + { name = "starlette", specifier = "==1.4.1" }, + { name = "typer", specifier = "==0.27.1" }, { name = "typing-extensions", specifier = "==4.16.0" }, { name = "typing-inspection", specifier = "==0.4.2" }, { name = "tzdata", specifier = "==2026.3" }, { name = "urllib3", specifier = "==2.7.0" }, - { name = "utm", specifier = "==0.8.1" }, - { name = "uvicorn", specifier = "==0.51.0" }, + { name = "utm", specifier = "==0.9.0" }, + { name = "uvicorn", specifier = "==0.52.1" }, { name = "yarl", specifier = "==1.24.5" }, ] @@ -1871,11 +1871,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -1964,11 +1964,11 @@ wheels = [ [[package]] name = "phonenumbers" -version = "9.0.35" +version = "9.0.36" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/9c/af506fdd7220cdf6fcea27a3b8d3dc5feb239f5072137c7900155c0c6cf9/phonenumbers-9.0.35.tar.gz", hash = "sha256:b19d97e8c448ccfd8d646888d2a65635372f75b9916006cc0a2f809b531baaea", size = 2306830, upload-time = "2026-07-26T08:28:17.303Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/21/3f1561d3527b27121d6872aeecb6df2a8de8e11af9c36f5a0bd3483d2655/phonenumbers-9.0.36.tar.gz", hash = "sha256:60ca2a6870d2532d6e960105790e85e9d69270ede81746d10cf57f5e437b93ec", size = 2307271, upload-time = "2026-08-01T06:13:44.954Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/35/80f35904033e5584966c255a0140f48934d18c4711c04d4bcbdf5b330bfb/phonenumbers-9.0.35-py2.py3-none-any.whl", hash = "sha256:57ef9787ddf2bc8cc0906d5c876d43fcd65fa25e7330d00b9f2ba8528870b72a", size = 2595440, upload-time = "2026-07-26T08:28:14.033Z" }, + { url = "https://files.pythonhosted.org/packages/39/5d/74fbcb16b29927bf378f4ec4d47c7b48488b93e1f1af64b26544daf1d55f/phonenumbers-9.0.36-py2.py3-none-any.whl", hash = "sha256:c16a5b95178345ec2df1954578a4ac09e937443b2ee992dd7b15a8e8c81f4acf", size = 2595528, upload-time = "2026-08-01T06:13:41.591Z" }, ] [[package]] @@ -2146,14 +2146,14 @@ wheels = [ [[package]] name = "proto-plus" -version = "1.28.2" +version = "1.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/73/3e/29e0d6a2c5adde6ab5772253fd16ab346324026b89a66e354689c86d0584/proto_plus-1.28.2.tar.gz", hash = "sha256:26d843eb99c1e32fdf1d20ff0faae56607f7748fe774acf9ecd5cfe6c6472501", size = 58063, upload-time = "2026-07-22T16:28:29.119Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/6a/056256feb4bd000869aba5c16cf2aa911572ca2a2feb185f86e457b5171e/proto_plus-1.28.3.tar.gz", hash = "sha256:5f91b30dafa6bb38d432c5557a6ee1d35ffd40b4b1e0e3ca27260448560b91d9", size = 58051, upload-time = "2026-08-06T06:24:55.581Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/84/4e9a53a062d4073c74897a6bd20fff74d55307341b3e85c081002462b3ef/proto_plus-1.28.2-py3-none-any.whl", hash = "sha256:b874236fcac2358f601e4330bcb76cb8b89c851303ccf4078408b3d4774d1c52", size = 50693, upload-time = "2026-07-22T16:28:24.059Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/cfee3c50294f55a2f0f9575052dec2c2a48891ad4b1c2a133b05a87026cd/proto_plus-1.28.3-py3-none-any.whl", hash = "sha256:dc76880b8ee951cca002098574376cf71e055f9f16d9ba6570fb8a06f726d281", size = 50795, upload-time = "2026-08-06T06:23:50.653Z" }, ] [[package]] @@ -2347,7 +2347,7 @@ wheels = [ [[package]] name = "pygeoapi" -version = "0.23.5" +version = "0.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -2369,9 +2369,9 @@ dependencies = [ { name = "sqlalchemy" }, { name = "tinydb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/bd/627893d1170ec92230cbcbdc2bb76732e9977d2addf3564f657676802a45/pygeoapi-0.23.5.tar.gz", hash = "sha256:9f1456738a8851c582f7336159dfaaa96ca9704b9c554c0cc59d699042314359", size = 372861, upload-time = "2026-07-14T13:54:56.417Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/be/6c658b14b0b73a301ae7c71298a0046cbea7ba08f029a22fa0a11394b9b6/pygeoapi-0.24.0.tar.gz", hash = "sha256:00cf5c88776284780bb8bcc82f4f48c52d25ddfefc9ba1a65a8f3f811670a0a3", size = 386049, upload-time = "2026-07-28T12:12:17.028Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/68/c5d54267698706a90bed15230f4d948c99d838dd4fbdabf9586797e2415c/pygeoapi-0.23.5-py2.py3-none-any.whl", hash = "sha256:66ec6c466f00a2ec4af77b886bc4b046d70a2fde4cdf21021e551231db955e19", size = 578221, upload-time = "2026-07-14T13:54:55.025Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f9/b5c7eb51bb73c102e331b7afdcb7413cb3e66418af4ad8910a19acf48caf/pygeoapi-0.24.0-py2.py3-none-any.whl", hash = "sha256:0ca8063121c19b86c90e896ed72758ce942be6c4a232062cc919ff7a8af36435", size = 599073, upload-time = "2026-07-28T12:12:15.854Z" }, ] [[package]] @@ -2873,14 +2873,14 @@ wheels = [ [[package]] name = "scramp" -version = "1.4.15" +version = "1.4.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asn1crypto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/6dd9b1e7ff7973eeb6550d0d609c3bdca8f27de2719a837628c9fd44c087/scramp-1.4.15.tar.gz", hash = "sha256:d25cdd3dbc493773647bccb93e4e85bbff0c091141ec8ff8d61bad1e3638082d", size = 20958, upload-time = "2026-07-26T17:49:06.655Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/3f/dc633be307f4f9f5e41f9374da6bb2b4f0332fd388b1d92306909a308bcf/scramp-1.4.16.tar.gz", hash = "sha256:836bcecdc8843af76b35e11adbe818e3d7934d0d25aa48fd2e8cde362a2c6a9b", size = 21210, upload-time = "2026-08-06T16:48:56.725Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/a4/6a6e67a8bdbc17b89537de2886cef62536d47c25b1f1d85ecfa81a4915f1/scramp-1.4.15-py3-none-any.whl", hash = "sha256:9d6102948d9005e3802384a328429dfd67d691a65791007c354ff89895857396", size = 15891, upload-time = "2026-07-26T17:49:04.913Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f0/5441d2bac230fa9a197c081672b57bdf2641e3988dddd39c687a3ad5267b/scramp-1.4.16-py3-none-any.whl", hash = "sha256:9e6148fca008ab6c6ce84658aeefd03d5d7f269d32fe08d07e134dae089e937a", size = 16083, upload-time = "2026-08-06T16:48:55.088Z" }, ] [[package]] @@ -3053,14 +3053,14 @@ wheels = [ [[package]] name = "starlette" -version = "1.3.1" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/3c/76d2fd1f1357ed0f0108d8a5aa233dcf16e2946a8559c84912fe08e01ac7/starlette-1.4.1.tar.gz", hash = "sha256:b7332de6e9375593a29ba9eee1e6ecfeb3eb2043e2e19a13b4b71da73ff35540", size = 2709041, upload-time = "2026-08-05T15:17:26.23Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/67e95f21498de41433babf7b1db0eeab449eb58872dfb831b27747a70fd0/starlette-1.4.1-py3-none-any.whl", hash = "sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19", size = 74019, upload-time = "2026-08-05T15:17:24.357Z" }, ] [[package]] @@ -3074,7 +3074,7 @@ wheels = [ [[package]] name = "typer" -version = "0.27.0" +version = "0.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -3082,9 +3082,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" }, ] [[package]] @@ -3158,24 +3158,24 @@ wheels = [ [[package]] name = "utm" -version = "0.8.1" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/c4/f7662574e0d8c883cea257a59efdc2dbb21f19f4a78e7c54be570d740f24/utm-0.8.1.tar.gz", hash = "sha256:634d5b6221570ddc6a1e94afa5c51bae92bcead811ddc5c9bc0a20b847c2dafa", size = 13128, upload-time = "2025-03-06T11:40:56.022Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/9a/d2cd5fec4f65f963fdd4f7a8a7f8992edeae7830751a1d8bd7261703f3fb/utm-0.9.0.tar.gz", hash = "sha256:1c8ffa6032631379374ceef05e6fea0ad42e9f09be0c3f91f7cc1b23f27be8a7", size = 13381, upload-time = "2026-07-27T15:34:48.603Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/a4/0698f3e5c397442ec9323a537e48cc63b846288b6878d38efd04e91005e3/utm-0.8.1-py3-none-any.whl", hash = "sha256:e3d5e224082af138e40851dcaad08d7f99da1cc4b5c413a7de34eabee35f434a", size = 8613, upload-time = "2025-03-06T11:40:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/45/d2/ccf2f47bfeb607db0c09865dea382f6ec5e6c9b93416f98452633f3c1c8b/utm-0.9.0-py3-none-any.whl", hash = "sha256:767592281e457dfacd71323ac69ff38e2f290d74526af91ca0924637de0e1d53", size = 8608, upload-time = "2026-07-27T15:34:47.651Z" }, ] [[package]] name = "uvicorn" -version = "0.51.0" +version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, ] [[package]] From e5748a540aec737136ac13795c2f9f917f198c37 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 10 Aug 2026 13:40:02 -0700 Subject: [PATCH 044/151] feat(geothermal): free-text search on the well list endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `q` to GET /thing/geothermal-well, matched case-insensitively as a substring across well name, API, well number, operator and county. The UI well picker cannot offer this catalogue as a scrollable list — it runs to thousands of wells, and the picker previously loaded the first 500 into a dropdown and silently dropped the rest. Narrowing has to happen server-side so the pagination envelope's `total` is the size of the match set, which is what the picker reports to the user. Whitespace-separated words are ANDed, so each word added narrows the result: "jemez 1" returns a subset of "jemez" rather than a union. ILIKE wildcards in the term are escaped, so a stray % matches a literal percent instead of silently widening the search to everything. `county` and `name_contains` are unchanged and compose with `q`. Tests compile the query and assert its shape, so they need no database: every column is searched, words AND rather than OR, wildcards are escaped, the geothermal base predicate survives, and ordering is preserved. Co-Authored-By: Claude Opus 5 --- api/geothermal.py | 16 ++++- services/geothermal_helper.py | 36 +++++++++- tests/test_geothermal_well_search.py | 99 ++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 tests/test_geothermal_well_search.py diff --git a/api/geothermal.py b/api/geothermal.py index 0e79368d9..8efd63094 100644 --- a/api/geothermal.py +++ b/api/geothermal.py @@ -25,7 +25,7 @@ from typing import Optional from uuid import UUID -from fastapi import APIRouter +from fastapi import APIRouter, Query from fastapi_pagination.ext.sqlalchemy import paginate from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND @@ -52,13 +52,25 @@ def get_geothermal_wells( session: session_dependency, county: Optional[str] = None, name_contains: Optional[str] = None, + q: Optional[str] = Query( + None, + description=( + "Free-text search across well name, API, well number, operator and " + "county. Whitespace-separated words are ANDed, so each word added " + "narrows the result. Case-insensitive substring match." + ), + ), ) -> CustomPage[GeothermalWellResponse]: """List geothermal wells. NOTE: sourced from the legacy NM_Wells mirror (NMW_WellHeaders where GthrmExist is set). Will be re-pointed at the thing table post-transform. + + ``q`` is what the UI well picker uses: the catalogue is far too large to + choose from by scrolling, so the term is matched server-side and the total + reported by the pagination envelope is the size of the match set. """ - sql = get_geothermal_wells_query(county=county, name_contains=name_contains) + sql = get_geothermal_wells_query(county=county, name_contains=name_contains, q=q) return paginate(query=sql, conn=session, transformer=geothermal_wells_transformer) diff --git a/services/geothermal_helper.py b/services/geothermal_helper.py index 5fc10f9fd..56b78931d 100644 --- a/services/geothermal_helper.py +++ b/services/geothermal_helper.py @@ -27,7 +27,7 @@ from uuid import UUID -from sqlalchemy import select +from sqlalchemy import or_, select from db.nmw_legacy import NMW_WellHeaders, NMW_WellLocations from schemas.geothermal import GeothermalWellResponse @@ -67,9 +67,40 @@ def _to_response(header: NMW_WellHeaders, location: NMW_WellLocations | None): ) +# Columns a free-text term is matched against. Everything a person might use +# to refer to a well: what it is called, how it is identified, and who ran it. +_SEARCH_COLUMNS = ( + NMW_WellHeaders.cur_well_nam, + NMW_WellHeaders.api, + NMW_WellHeaders.cur_well_num, + NMW_WellHeaders.cur_operatr, + NMW_WellLocations.county, +) + + +def _search_clauses(q: str): + """One clause per whitespace-separated word, each matching any column. + + Words are ANDed so that adding a word narrows the result — "jemez 1" + returns a subset of "jemez" rather than everything matching either. ILIKE + wildcards in the term are escaped so a stray % does not silently widen the + search to everything. + """ + clauses = [] + for word in q.split(): + pattern = "%{}%".format( + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + ) + clauses.append( + or_(*[column.ilike(pattern, escape="\\") for column in _SEARCH_COLUMNS]) + ) + return clauses + + def get_geothermal_wells_query( county: str | None = None, name_contains: str | None = None, + q: str | None = None, ): """Build the list query; returned as a SQLAlchemy select for pagination.""" sql = _base_query() @@ -77,6 +108,9 @@ def get_geothermal_wells_query( sql = sql.where(NMW_WellLocations.county == county) if name_contains: sql = sql.where(NMW_WellHeaders.cur_well_nam.ilike(f"%{name_contains}%")) + if q and q.strip(): + for clause in _search_clauses(q.strip()): + sql = sql.where(clause) return sql.order_by(NMW_WellHeaders.cur_well_nam) diff --git a/tests/test_geothermal_well_search.py b/tests/test_geothermal_well_search.py new file mode 100644 index 000000000..7c39c7940 --- /dev/null +++ b/tests/test_geothermal_well_search.py @@ -0,0 +1,99 @@ +# =============================================================================== +# Copyright 2026 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +"""Free-text search on the geothermal well list query. + +The UI well picker cannot offer the catalogue as a scrollable list, so it sends +a term and relies on the server to narrow it. These tests compile the query and +assert its shape, which needs no database. +""" + +from services.geothermal_helper import get_geothermal_wells_query + +# Columns `q` is matched against, by their legacy NM_Wells names. +SEARCH_COLUMNS = ("CurWellNam", "API", "CurWellNum", "CurOperatr", "County") + + +def compiled(**kwargs) -> str: + sql = get_geothermal_wells_query(**kwargs) + return str(sql.compile(compile_kwargs={"literal_binds": True})) + + +def test_no_search_term_adds_no_predicate(): + """An empty picker lists wells; it must not search for nothing.""" + baseline = compiled() + + assert compiled(q=None) == baseline + assert compiled(q="") == baseline + assert compiled(q=" ") == baseline + assert "lower" not in baseline.lower() or "ilike" not in baseline.lower() + + +def test_term_matches_every_search_column(): + sql = compiled(q="jemez") + + for column in SEARCH_COLUMNS: + assert column in sql, f"{column} is not searched" + assert sql.lower().count("%jemez%") == len(SEARCH_COLUMNS) + + +def test_term_is_case_insensitive_substring(): + sql = compiled(q="jemez").lower() + + # ILIKE compiles to lower(...) LIKE lower(...) on some dialects; either way + # the match is a substring wrapped in wildcards, not an equality. + assert "%jemez%" in sql + assert "like" in sql + + +def test_words_are_anded_so_each_one_narrows(): + """ "jemez 1" must be a subset of "jemez", not a union.""" + one_word = compiled(q="jemez") + two_words = compiled(q="jemez 1") + + assert "%jemez%" in two_words + assert "%1%" in two_words + # A second word adds a second bracketed OR group rather than extending the + # first, which is what makes the predicates AND together. + assert two_words.count("OR") > one_word.count("OR") + + +def test_wildcards_in_the_term_are_escaped(): + """A stray % must not silently widen the search to the whole catalogue.""" + sql = compiled(q="50%") + + assert r"%50\%%" in sql + assert "ESCAPE" in sql.upper() + + underscore = compiled(q="a_b") + assert r"%a\_b%" in underscore + + +def test_search_composes_with_the_existing_filters(): + sql = compiled(q="jemez", county="Sandoval") + + assert "%jemez%" in sql + assert "Sandoval" in sql + # The geothermal flag is the base predicate and must survive. + assert "GthrmExist" in sql + + +def test_results_stay_ordered_by_name(): + assert ( + compiled(q="jemez").rstrip().endswith('ORDER BY "NMW_WellHeaders"."CurWellNam"') + ) + + +# ============= EOF ============================================= From e448741fe6a733a72f2e994b239c915dc4318ca0 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 13 Aug 2026 13:11:43 -0700 Subject: [PATCH 045/151] fix(db): repair EDR water views skipped by a stamped revision The staging database is stamped past z9a0b1c2d3e4 without that revision's DDL ever having run, so every OGC API - EDR query 500s with: psycopg2.errors.UndefinedTable: relation "ogc_waterlevels" does not exist The CD run carrying z9a0b1c2d3e4 to staging failed in the Alembic step with "Multiple head revisions are present for given argument 'head'". The graph was repaired afterwards (eb89d046, 8ae9fe18), but the database came out the other side with the revision recorded and its views absent. Downstream revisions applied normally, so nothing surfaced until an EDR request hit the missing relation. Re-running the revision is not possible (alembic_version already lists it) and downgrading to it would tear out every revision since, so this closes the hole from the front of the chain instead. The view SQL is imported from z9a0b1c2d3e4 rather than copied, so the repaired definition cannot drift from the definition of record. A view that is already present as a plain view is left untouched, making this a no-op on healthy databases; a name occupied by some other relation kind fails loudly rather than being replaced. downgrade() is deliberately a no-op, since the views belong to z9a0b1c2d3e4. Verified against ocotilloapi_test: no-op on a healthy database (viewdefs byte-identical), both views restored byte-identical after simulating the staging state, and a materialized view squatting the name raises. Co-Authored-By: Claude Opus 5 --- ...d9e0f1a2_repair_missing_edr_water_views.py | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 alembic/versions/b7c8d9e0f1a2_repair_missing_edr_water_views.py diff --git a/alembic/versions/b7c8d9e0f1a2_repair_missing_edr_water_views.py b/alembic/versions/b7c8d9e0f1a2_repair_missing_edr_water_views.py new file mode 100644 index 000000000..572c14b89 --- /dev/null +++ b/alembic/versions/b7c8d9e0f1a2_repair_missing_edr_water_views.py @@ -0,0 +1,126 @@ +"""repair missing EDR water views + +Recreates ogc_waterlevels / ogc_water_chemistry on any database whose +alembic_version claims z9a0b1c2d3e4 was applied while the views are in fact +absent. + +Why this is needed: the CD run that first carried z9a0b1c2d3e4 to staging +failed in the Alembic step with "Multiple head revisions are present for given +argument 'head'". The revision graph was then repaired in-tree (eb89d046, +8ae9fe18), but the staging database came out the other side stamped past +z9a0b1c2d3e4 without its DDL ever having executed. Downstream revisions applied +normally, so nothing surfaced until an EDR query hit the missing relation: + + psycopg2.errors.UndefinedTable: relation "ogc_waterlevels" does not exist + +Re-running z9a0b1c2d3e4 is not an option -- alembic_version already lists it, +and downgrading to it would tear out every revision since. This revision closes +the hole from the front of the chain instead. + +The view SQL is imported from z9a0b1c2d3e4 rather than copied so the repaired +definition cannot drift from the definition of record. + +Idempotent and safe on healthy databases: a view that is already present is +left untouched, so this is a no-op everywhere except the environments that +actually skipped the original revision. + +Revision ID: b7c8d9e0f1a2 +Revises: f3a1c2b4d5e6 +Create Date: 2026-08-13 13:20:00.000000 +""" + +import importlib.util +from pathlib import Path +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "b7c8d9e0f1a2" +down_revision: Union[str, Sequence[str], None] = "f3a1c2b4d5e6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_SOURCE_REVISION = "z9a0b1c2d3e4_add_edr_water_views.py" + + +def _load_source_revision(): + # The view definitions live in z9a0b1c2d3e4. Importing them keeps this + # repair honest: whatever that revision creates is exactly what a database + # that skipped it gets back. + path = Path(__file__).with_name(_SOURCE_REVISION) + if not path.exists(): + raise RuntimeError( + f"Cannot repair the EDR water views: {_SOURCE_REVISION} is missing " + "from alembic/versions, so the view definitions of record are " + "unavailable." + ) + spec = importlib.util.spec_from_file_location("_edr_water_views", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +VIEW_COMMENTS = { + "ogc_waterlevels": ( + "Public depth-to-water readings (manual + transducer) for EDR." + ), + "ogc_water_chemistry": "Public water-chemistry analyses (by analyte) for EDR.", +} + + +def _relkind(view_name: str) -> str | None: + bind = op.get_bind() + return bind.execute( + text("SELECT relkind FROM pg_class WHERE oid = to_regclass(:name)"), + {"name": view_name}, + ).scalar() + + +def _check_required_tables(required_tables: set[str]) -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing = set(inspector.get_table_names(schema="public")) + missing = required_tables - existing + if missing: + raise RuntimeError( + "Cannot repair the EDR water views. Missing required tables: " + f"{sorted(missing)}" + ) + + +def _repair_view(view_name: str, create_sql: str) -> None: + relkind = _relkind(view_name) + if relkind == "v": + # Already present and the right kind -- the database applied + # z9a0b1c2d3e4 for real. Leave it alone rather than churning DDL that + # other objects may depend on. + return + if relkind is not None: + # Present as something other than a plain view (materialized view, + # table). That is not a state z9a0b1c2d3e4 or its downstream revisions + # produce, so fail loudly instead of silently replacing it. + raise RuntimeError( + f"Cannot repair {view_name}: it already exists with relkind " + f"{relkind!r}, not a plain view. Inspect it by hand before " + "re-running this migration." + ) + + op.execute(text(create_sql)) + op.execute(text(f"COMMENT ON VIEW {view_name} IS '{VIEW_COMMENTS[view_name]}'")) + + +def upgrade() -> None: + source = _load_source_revision() + _check_required_tables(set(source.REQUIRED_TABLES)) + + _repair_view("ogc_waterlevels", source._create_waterlevels_view()) + _repair_view("ogc_water_chemistry", source._create_water_chemistry_view()) + + +def downgrade() -> None: + # Deliberately a no-op. These views belong to z9a0b1c2d3e4; dropping them + # here would break EDR on every database that applied that revision + # correctly. Downgrading past z9a0b1c2d3e4 removes them. + pass From bb447f67e426217001cae620b2462ed81ad5c664 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 13 Aug 2026 14:23:55 -0700 Subject: [PATCH 046/151] ci: add a workflow_dispatch job for data migrations Data migrations in data_migrations/migrations/ have no CI path. The deploy workflows run `alembic upgrade head` and stop there, so a registered data migration stays unapplied until someone runs `oco data-migrations run` by hand against a live database -- which needs Cloud SQL credentials most of the team does not have locally. 20260714_0001_publish_project_areas has been sitting unapplied on staging since July 14 for exactly this reason, leaving the project_areas OGC collection advertised and empty. This workflow runs them with the same environment secrets the deploys already use, so no local credentials are involved. Inputs: environment (staging/production, selecting the GitHub Environment and therefore its secrets), action (status / run-all / run), migration_id, include_repeatable, and force. The default action is status, which applies nothing. The action is bracketed by a status report before and after, so one run's log shows what changed. A `run` without a migration_id fails in a validation step before any credential is touched, rather than after connecting. Concurrency is keyed on the environment: these write to a live database, and two concurrent runs could both pass the "already applied" check. Co-Authored-By: Claude Opus 5 --- .github/workflows/data_migrations.yml | 139 ++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 .github/workflows/data_migrations.yml diff --git a/.github/workflows/data_migrations.yml b/.github/workflows/data_migrations.yml new file mode 100644 index 000000000..b8af98622 --- /dev/null +++ b/.github/workflows/data_migrations.yml @@ -0,0 +1,139 @@ +name: Data Migrations + +# Data migrations (data_migrations/migrations/) are not part of CD. The deploy +# workflows only run `alembic upgrade head`, so a registered data migration sits +# unapplied until someone runs it by hand against a live database -- which needs +# Cloud SQL credentials most people do not have locally. This workflow runs them +# with the same environment secrets the deploys already use. +# +# Start with action = status. It prints what is registered, what has been +# applied, and when. It applies no migration, though it is not strictly +# read-only: get_status() calls ensure_history_table(), so a database that has +# never run one gets an empty data_migration_history table created. + +on: + workflow_dispatch: + inputs: + environment: + description: "Target environment" + type: choice + options: + - staging + - production + default: staging + action: + description: "status (applies nothing) | run-all | run (single migration)" + type: choice + options: + - status + - run-all + - run + default: status + migration_id: + description: "Migration id -- required when action = run" + type: string + default: "" + include_repeatable: + description: "Include repeatable migrations (action = run-all)" + type: boolean + default: false + force: + description: "Re-run migrations already recorded as applied" + type: boolean + default: false + +permissions: + contents: read + +# One run at a time per environment: these write to a live database, and two +# concurrent runs could both pass the "already applied" check. +concurrency: + group: data-migrations-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + data-migrations: + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + + steps: + - name: Validate inputs + run: | + if [ "${{ inputs.action }}" = "run" ] && [ -z "${{ inputs.migration_id }}" ]; then + echo "::error::action = run requires migration_id." + echo "::error::Run this workflow with action = status to list the registered ids." + exit 1 + fi + if [ "${{ inputs.action }}" = "status" ] && [ "${{ inputs.force }}" = "true" ]; then + echo "::warning::force has no effect on a status check." + fi + + - name: Check out source repository + uses: actions/checkout@v7.0.1 + + - name: Install uv in container + uses: astral-sh/setup-uv@v9.0.0 + with: + version: "latest" + + - name: Authenticate to Google Cloud + uses: "google-github-actions/auth@v3" + with: + credentials_json: ${{ secrets.CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY }} + + - name: Report target + run: | + echo "Environment: ${{ inputs.environment }}" + echo "Database: ${{ vars.CLOUD_SQL_DATABASE }}" + echo "Action: ${{ inputs.action }}" + + # Runs before the action so the log shows the before/after pair on a + # single run, and so a status-only run needs no second step. + - name: Status before + env: + DB_DRIVER: "cloudsql" + CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" + CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" + CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + CLOUD_SQL_IAM_AUTH: true + run: uv run --no-dev oco data-migrations status + + - name: Apply migrations + if: inputs.action != 'status' + env: + DB_DRIVER: "cloudsql" + CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" + CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" + CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + CLOUD_SQL_IAM_AUTH: true + run: | + set -euo pipefail + + args=() + if [ "${{ inputs.action }}" = "run" ]; then + args=(run "${{ inputs.migration_id }}") + if [ "${{ inputs.force }}" = "true" ]; then + args+=(--force) + fi + else + args=(run-all) + if [ "${{ inputs.include_repeatable }}" = "true" ]; then + args+=(--include-repeatable) + fi + if [ "${{ inputs.force }}" = "true" ]; then + args+=(--force) + fi + fi + + echo "oco data-migrations ${args[*]}" + uv run --no-dev oco data-migrations "${args[@]}" + + - name: Status after + if: inputs.action != 'status' + env: + DB_DRIVER: "cloudsql" + CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" + CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" + CLOUD_SQL_USER: "${{ secrets.CLOUD_SQL_USER }}" + CLOUD_SQL_IAM_AUTH: true + run: uv run --no-dev oco data-migrations status From 395a63c95712d241d93960442ff9c94981503a90 Mon Sep 17 00:00:00 2001 From: Likitha Bommasani Date: Thu, 13 Aug 2026 14:58:11 -0700 Subject: [PATCH 047/151] fix: correct water_wells collection name in README examples and Added time_field(BDMS-973) (#823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Why - README examples referenced a wells collection that doesn't exist — both example URLs 404'd (Section 4.6, R7), the first thing a new developer hits when following the docs. - Correct collection name is water_wells. ### How - Updated README.md lines 50 & 59: collections/wells/ → collections/water_wells/. - Discovered the datetime-filtered example still failed (500) after the rename, because no Thing-based collection (water_wells, springs, diversions_surface_water, ephemeral_streams, lakes_ponds_reservoirs) had a time_field configured, so pygeoapi rejected any ?datetime= query. - Added "time_field": "first_visit_date" in _thing_collections_block (core/pygeoapi.py) — the one date column populated across all five collections — fixing datetime filtering for all of them, not just water_wells. ### Notes - Verified both README example URLs now return 200 with valid GeoJSON against a local stack. - well_completion_date was considered as the time_field but is there are many NULL values , so it wasn't usable - Can be discussed. - ogc_locations collection is untouched — it's set up separately and wasn't part of this bug. - 2 unrelated tests fail locally (test_ogc_wells_items_and_item, test_ogc_project_areas_items_expose_groups_with_project_areas). Confirmed they fail the same way even without my change — leftover data in the local test database, not caused by this fix. --- README.md | 4 ++-- core/pygeoapi.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7a1248d1c..656d47556 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ curl http://localhost:8000/ogcapi/collections/locations ```bash curl "http://localhost:8000/ogcapi/collections/locations/items?limit=10&offset=0" -curl "http://localhost:8000/ogcapi/collections/wells/items?limit=5" +curl "http://localhost:8000/ogcapi/collections/water_wells/items?limit=5" curl "http://localhost:8000/ogcapi/collections/springs/items?limit=5" curl "http://localhost:8000/ogcapi/collections/locations/items/123" ``` @@ -56,7 +56,7 @@ curl "http://localhost:8000/ogcapi/collections/locations/items/123" ```bash curl "http://localhost:8000/ogcapi/collections/locations/items?bbox=-107.9,33.8,-107.8,33.9" -curl "http://localhost:8000/ogcapi/collections/wells/items?datetime=2020-01-01/2024-01-01" +curl "http://localhost:8000/ogcapi/collections/water_wells/items?datetime=2020-01-01/2024-01-01" ``` ### Polygon filter (CQL2 text) diff --git a/core/pygeoapi.py b/core/pygeoapi.py index 7a3a35126..377d77c0f 100644 --- a/core/pygeoapi.py +++ b/core/pygeoapi.py @@ -267,6 +267,7 @@ def _thing_collections_block( "id_field": "id", "table": f"{table_prefix}{collection['id']}", "geom_field": "point", + "time_field": "first_visit_date", } ], } From 88634304f44b8a445f60b8e909e356f5c1f73080 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 13 Aug 2026 16:45:44 -0700 Subject: [PATCH 048/151] fix(edr): source water-chemistry EDR from the legacy NMA tables The water_chemistry EDR collection is advertised in /ogcapi/collections and returns an empty FeatureCollection on staging. The views behind it (z9a0b1c2d3e4, mirrored by 2d3c3a268652) read the normalized chain -- observation -> sample -> field_activity -> field_event -> thing -- and nothing populates that chain with analyte data. Per docs/chemistry-ingestion-runbook.md the live ingestion path writes only to the legacy NMA_* tables, which is why ogc_major_chemistry_results and ogc_minor_chemistry_wells serve thousands of rows from the same database. This repoints both EDR chemistry views at the legacy tables at the per-result grain EDR needs, unioning the four families that hang off NMA_Chemistry_SampleInfo: major, minor/trace, radionuclides, and field parameters. Field parameters carry no analysis date of their own and ride on the sample's CollectionDate; rows that end up with no timestamp are dropped, since EDR needs a time axis. Interim by design. When chemistry reaches the normalized model the views move back and the EDR contract does not change -- same collection, same parameter-names, same CoverageJSON. downgrade() restores the normalized definitions by importing them from the revisions that own them rather than copying, so they cannot drift. Three deliberate differences from the pivot views, documented in the revision: no thing_type filter (chemistry at a spring is still chemistry); publication gated on thing.release_status plus NMA_Chemistry_SampleInfo."PublicRelease" not being explicitly false; and parameter_name taken as raw trimmed analyte text rather than canonicalized, which leaves ADR3's chemistry-cardinality question open but reachable. Verified against ocotilloapi_test with seeded rows across all four families: public view returns the public well and the spring, excludes a draft thing and a PublicRelease = false sample, and drops a NULL-analyte row; internal mirror returns those two extra rows. The provider's own queries (_read projection, get_fields DISTINCT, bbox/WKT/datetime predicates) all run against the view, and a downgrade/upgrade cycle restores each definition. Co-Authored-By: Claude Opus 5 --- ..._edr_water_chemistry_from_legacy_tables.py | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py diff --git a/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py b/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py new file mode 100644 index 000000000..2d6c6e1e0 --- /dev/null +++ b/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py @@ -0,0 +1,287 @@ +"""rebuild the EDR water-chemistry views on the legacy NMA chemistry tables + +ogc_water_chemistry (z9a0b1c2d3e4) and its internal mirror (2d3c3a268652) read +the normalized chain -- observation -> sample -> field_activity -> field_event +-> thing. Nothing populates that chain with analyte data: per +docs/chemistry-ingestion-runbook.md, the live ingestion path +(services/chemistry_lims.py, services/chemistry_drive.py, `oco water-chemistry +bulk-upload`) writes only to the legacy NMA_* tables. So the EDR collection is +advertised in /ogcapi/collections and returns an empty FeatureCollection, while +ogc_major_chemistry_results and ogc_minor_chemistry_wells -- both built on the +same legacy tables -- serve thousands of rows. + +This revision repoints both EDR chemistry views at the legacy tables, at the +per-result grain EDR needs (one row per analyte measurement, not the per-well +summary the pivot views produce). Four families are unioned, all sharing the +same shape via NMA_Chemistry_SampleInfo: + + NMA_MajorChemistry "Analyte"/"Symbol", "SampleValue", "Units" + NMA_MinorTraceChemistry analyte/symbol, sample_value, units + NMA_Radionuclides "Analyte"/"Symbol", "SampleValue", "Units" + NMA_FieldParameters "FieldParameter", "SampleValue", "Units" + +This is interim. When chemistry lands in the normalized Sample/Observation +model, the views move back and the EDR contract does not change -- consumers +see the same collection, parameter-names, and CoverageJSON either way. + +Three deliberate differences from the pivot views, each of which would +otherwise be a silent surprise: + +* No thing_type filter. ogc_major_chemistry_results restricts to + thing_type = 'water well' because it is a wells layer; this is a chemistry + collection, so chemistry collected at a spring belongs in it. +* Publication is gated on thing.release_status = 'public' (the convention + f4a5b6c7d8e9 established for the legacy-backed views) AND on + NMA_Chemistry_SampleInfo."PublicRelease" not being explicitly false. The + pivot views ignore PublicRelease; honouring it here errs toward + withholding, and NULL is treated as "not suppressed" so the two layers stay + consistent on the rows that carry no opinion. +* parameter_name is the raw trimmed legacy analyte text, falling back to the + symbol. The pivot views canonicalize analytes through long CASE blocks, but + those cover only the subset they expose as columns. Raw text keeps every + analyte reachable at the cost of aliases appearing as separate + parameter-names ("Ca" and "Calcium" both surface). That is ADR3's open + "chemistry parameter cardinality" question; canonicalizing is follow-up work + and changes only the parameter-name vocabulary, not this plumbing. + +Rows without a usable timestamp are dropped: EDR needs a time axis, and +COALESCE(analysis date, collection date) is the best available. Field +parameters carry no analysis date of their own, so they ride on the sample's +CollectionDate. + +Revision ID: d9e0f1a2b3c4 +Revises: b7c8d9e0f1a2 +Create Date: 2026-08-13 15:40:00.000000 +""" + +import importlib.util +from pathlib import Path +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "d9e0f1a2b3c4" +down_revision: Union[str, Sequence[str], None] = "b7c8d9e0f1a2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +REQUIRED_TABLES = { + "NMA_Chemistry_SampleInfo", + "NMA_MajorChemistry", + "NMA_MinorTraceChemistry", + "NMA_Radionuclides", + "NMA_FieldParameters", + "thing", + "location", + "location_thing_association", +} + +PUBLIC_VIEW = "ogc_water_chemistry" +INTERNAL_VIEW = "ogc_internal_water_chemistry" + +VIEW_COMMENTS = { + PUBLIC_VIEW: ( + "Public water-chemistry analyses (by analyte) for EDR, sourced from " + "the legacy NMA chemistry tables." + ), + INTERNAL_VIEW: ( + "All water-chemistry analyses (by analyte) for internal EDR, sourced " + "from the legacy NMA chemistry tables." + ), +} + +# Same latest-location shape the other ogc_* views use (d5e6f7a8b9c0). +_LATEST_LOCATION_CTE = """ + SELECT DISTINCT ON (lta.thing_id) + lta.thing_id, + lta.location_id, + lta.effective_start + FROM location_thing_association AS lta + WHERE lta.effective_end IS NULL + ORDER BY lta.thing_id, lta.effective_start DESC +""" + + +def _result_family( + *, + id_prefix: str, + table: str, + analyte_column: str, + value_column: str, + unit_column: str, + date_column: str | None, +) -> str: + """One SELECT over a legacy chemistry table, normalized to a common shape. + + ``date_column`` is None for NMA_FieldParameters, which has no analysis + date of its own and falls back to the sample's CollectionDate. + """ + observed_at = ( + f'COALESCE(r.{date_column}, csi."CollectionDate")' + if date_column + else 'csi."CollectionDate"' + ) + return f""" + SELECT + '{id_prefix}-' || r.id AS id, + csi.id AS sample_id, + csi.thing_id AS thing_id, + csi."PublicRelease" AS sample_public_release, + {observed_at} AS datetime, + r.{value_column}::double precision AS value, + r.{unit_column} AS unit, + NULLIF(trim({analyte_column}), '') AS parameter_name + FROM "{table}" AS r + JOIN "NMA_Chemistry_SampleInfo" AS csi + ON csi.id = r.chemistry_sample_info_id + WHERE r.{value_column} IS NOT NULL + """ + + +def _result_families() -> str: + families = [ + _result_family( + id_prefix="maj", + table="NMA_MajorChemistry", + analyte_column='COALESCE(r."Analyte", r."Symbol")', + value_column='"SampleValue"', + unit_column='"Units"', + date_column='"AnalysisDate"', + ), + _result_family( + id_prefix="min", + table="NMA_MinorTraceChemistry", + analyte_column="COALESCE(r.analyte, r.symbol)", + value_column="sample_value", + unit_column="units", + date_column="analysis_date", + ), + _result_family( + id_prefix="rad", + table="NMA_Radionuclides", + analyte_column='COALESCE(r."Analyte", r."Symbol")', + value_column='"SampleValue"', + unit_column='"Units"', + date_column='"AnalysisDate"', + ), + _result_family( + id_prefix="fld", + table="NMA_FieldParameters", + analyte_column='r."FieldParameter"', + value_column='"SampleValue"', + unit_column='"Units"', + date_column=None, + ), + ] + return "\n UNION ALL\n".join(families) + + +def _create_water_chemistry_view(view_name: str, public_only: bool) -> str: + release_filter = ( + """ + AND t.release_status = 'public' + AND results.sample_public_release IS NOT FALSE""" + if public_only + else "" + ) + return f""" + CREATE VIEW {view_name} AS + WITH latest_location AS ( + {_LATEST_LOCATION_CTE} + ), + results AS ( + {_result_families()} + ) + SELECT + results.id AS id, + t.id AS thing_id, + t.name AS station_name, + ST_X(l.point) AS longitude, + ST_Y(l.point) AS latitude, + results.datetime AS datetime, + results.value AS value, + results.unit AS unit, + results.parameter_name AS parameter_name, + results.sample_id AS sample_id, + t.release_status AS release_status + FROM results + JOIN thing AS t ON t.id = results.thing_id + JOIN latest_location AS ll ON ll.thing_id = t.id + JOIN location AS l ON l.id = ll.location_id + WHERE results.parameter_name IS NOT NULL + AND results.datetime IS NOT NULL{release_filter} + """ + + +def _load_revision_module(filename: str, module_name: str): + path = Path(__file__).with_name(filename) + if not path.exists(): + raise RuntimeError( + f"Cannot restore the previous EDR chemistry views: {filename} is " + "missing from alembic/versions." + ) + spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _drop_view_or_materialized_view(view_name: str) -> None: + # DROP VIEW IF EXISTS only suppresses "relation does not exist" -- Postgres + # still raises WrongObjectType if the relation is a materialized view, so + # check the actual kind first. + bind = op.get_bind() + relkind = bind.execute( + text("SELECT relkind FROM pg_class WHERE oid = to_regclass(:name)"), + {"name": view_name}, + ).scalar() + if relkind == "m": + op.execute(text(f"DROP MATERIALIZED VIEW IF EXISTS {view_name}")) + elif relkind == "v": + op.execute(text(f"DROP VIEW IF EXISTS {view_name}")) + + +def _check_required_tables() -> None: + bind = op.get_bind() + inspector = inspect(bind) + existing = set(inspector.get_table_names(schema="public")) + missing = REQUIRED_TABLES - existing + if missing: + raise RuntimeError( + "Cannot rebuild the EDR water-chemistry views. Missing required " + f"tables: {sorted(missing)}" + ) + + +def upgrade() -> None: + _check_required_tables() + + for view_name, public_only in ((PUBLIC_VIEW, True), (INTERNAL_VIEW, False)): + _drop_view_or_materialized_view(view_name) + op.execute(text(_create_water_chemistry_view(view_name, public_only))) + op.execute(text(f"COMMENT ON VIEW {view_name} IS '{VIEW_COMMENTS[view_name]}'")) + + +def downgrade() -> None: + # Restore the normalized-model definitions from the revisions that own + # them, rather than a copy that could drift from those files. + edr = _load_revision_module( + "z9a0b1c2d3e4_add_edr_water_views.py", "_edr_water_views" + ) + internal = _load_revision_module( + "2d3c3a268652_create_internal_ogc_views.py", "_internal_ogc_views" + ) + + _drop_view_or_materialized_view(PUBLIC_VIEW) + op.execute(text(edr._create_water_chemistry_view())) + op.execute( + text( + "COMMENT ON VIEW ogc_water_chemistry IS " + "'Public water-chemistry analyses (by analyte) for EDR.'" + ) + ) + + _drop_view_or_materialized_view(INTERNAL_VIEW) + op.execute(text(internal._create_internal_water_chemistry_view())) From aac3d8714512c840b6930d640d54e0fbd4e0a586 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 13 Aug 2026 16:58:02 -0700 Subject: [PATCH 049/151] fix(edr): expose thing_type and materialize the chemistry coverages Two follow-ups to the legacy-backed chemistry views. thing_type is now a column on both views and, when the backing relation has it, a property on the /locations features the provider returns. Since these views deliberately carry no thing_type filter -- chemistry collected at a spring is still chemistry -- a consumer otherwise had no way to tell a well from a spring. ogc_waterlevels has no such column; the provider detects it rather than assuming, so that collection is untouched. Both views become MATERIALIZED, matching ogc_major_chemistry_results and ogc_minor_chemistry_wells. As plain views, every request re-planned a four-way UNION over the full legacy result tables, and get_fields() runs SELECT DISTINCT parameter_name, unit at provider construction -- a full scan per request. The staging and production tables are already well past the point where that is affordable. Indexes cover the provider's three filter columns, and the unique index on id allows CONCURRENTLY refreshes. Freshness now matches the other chemistry layers: the nightly pg_cron job discovers materialized views from the catalog, and both are registered in services/materialized_views.py for `oco refresh-materialized-views` after an ad-hoc ingestion. Column detection reads pg_attribute, not information_schema.columns, which does not list materialized views -- detection silently returned False against the materialized views until this was caught end-to-end. Verified against ocotilloapi_test: both relations are relkind 'm' with the four expected indexes; CONCURRENTLY refresh succeeds and picks up new rows; the provider reports thing_type on the chemistry matview, omits it on the ogc_waterlevels view, and returns False rather than raising for a missing relation. Co-Authored-By: Claude Opus 5 --- ..._edr_water_chemistry_from_legacy_tables.py | 49 +++++++++++++++++-- core/edr_provider.py | 38 +++++++++++++- services/materialized_views.py | 4 ++ tests/test_cli_commands.py | 10 ++-- tests/test_edr_provider.py | 32 ++++++++++++ 5 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 tests/test_edr_provider.py diff --git a/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py b/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py index 2d6c6e1e0..1cd253665 100644 --- a/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py +++ b/alembic/versions/d9e0f1a2b3c4_edr_water_chemistry_from_legacy_tables.py @@ -29,7 +29,10 @@ * No thing_type filter. ogc_major_chemistry_results restricts to thing_type = 'water well' because it is a wells layer; this is a chemistry - collection, so chemistry collected at a spring belongs in it. + collection, so chemistry collected at a spring belongs in it. thing_type is + carried as a column instead, so a consumer can tell a well from a spring + rather than having the distinction silently dropped -- the EDR provider + surfaces it on /locations features when the backing view has the column. * Publication is gated on thing.release_status = 'public' (the convention f4a5b6c7d8e9 established for the legacy-backed views) AND on NMA_Chemistry_SampleInfo."PublicRelease" not being explicitly false. The @@ -49,6 +52,21 @@ parameters carry no analysis date of their own, so they ride on the sample's CollectionDate. +Both are MATERIALIZED views, matching ogc_major_chemistry_results and +ogc_minor_chemistry_wells. A plain view would be re-planned on every request +across a four-way UNION of the full legacy result tables, and the provider's +get_fields() runs SELECT DISTINCT parameter_name, unit at provider +construction -- a full scan per request, against tables that already hold far +more than the pivot views' per-well row counts suggest. Indexes cover the +provider's three filter columns (thing_id, datetime, parameter_name), and the +unique index on id is what allows CONCURRENTLY refreshes. + +The cost is staleness: the nightly pg_cron job discovers every materialized +view from the catalog (x2y3z4a5b6c7), so these refresh with the rest, and +services/materialized_views.py lists them for `oco refresh-materialized-views` +after an ad-hoc chemistry ingestion. That is the same freshness contract the +existing chemistry layers already have. + Revision ID: d9e0f1a2b3c4 Revises: b7c8d9e0f1a2 Create Date: 2026-08-13 15:40:00.000000 @@ -187,7 +205,7 @@ def _create_water_chemistry_view(view_name: str, public_only: bool) -> str: else "" ) return f""" - CREATE VIEW {view_name} AS + CREATE MATERIALIZED VIEW {view_name} AS WITH latest_location AS ( {_LATEST_LOCATION_CTE} ), @@ -198,6 +216,7 @@ def _create_water_chemistry_view(view_name: str, public_only: bool) -> str: results.id AS id, t.id AS thing_id, t.name AS station_name, + t.thing_type AS thing_type, ST_X(l.point) AS longitude, ST_Y(l.point) AS latitude, results.datetime AS datetime, @@ -255,13 +274,37 @@ def _check_required_tables() -> None: ) +def _create_indexes(view_name: str) -> None: + # The unique index is what lets REFRESH MATERIALIZED VIEW CONCURRENTLY run + # (`oco refresh-materialized-views --concurrently`); Postgres refuses + # without one. id is unique by construction -- each family prefixes its own + # primary key. + op.execute(text(f"CREATE UNIQUE INDEX ux_{view_name}_id ON {view_name} (id)")) + # The provider filters on thing_id (locations / position), datetime + # (interval), and parameter_name (parameter-name), so each gets an index. + op.execute(text(f"CREATE INDEX ix_{view_name}_thing_id ON {view_name} (thing_id)")) + op.execute(text(f"CREATE INDEX ix_{view_name}_datetime ON {view_name} (datetime)")) + op.execute( + text( + f"CREATE INDEX ix_{view_name}_parameter_name " + f"ON {view_name} (parameter_name)" + ) + ) + + def upgrade() -> None: _check_required_tables() for view_name, public_only in ((PUBLIC_VIEW, True), (INTERNAL_VIEW, False)): _drop_view_or_materialized_view(view_name) op.execute(text(_create_water_chemistry_view(view_name, public_only))) - op.execute(text(f"COMMENT ON VIEW {view_name} IS '{VIEW_COMMENTS[view_name]}'")) + _create_indexes(view_name) + op.execute( + text( + f"COMMENT ON MATERIALIZED VIEW {view_name} IS " + f"'{VIEW_COMMENTS[view_name]}'" + ) + ) def downgrade() -> None: diff --git a/core/edr_provider.py b/core/edr_provider.py index db377af5b..35815d089 100644 --- a/core/edr_provider.py +++ b/core/edr_provider.py @@ -95,6 +95,12 @@ def __init__(self, provider_def): self._fields = {} self.get_fields() + # Station metadata carried by some backing views but not others: the + # chemistry views (d9e0f1a2b3c4) span wells and springs and expose + # thing_type so a consumer can tell them apart. Detected rather than + # assumed, so a view without the column keeps working unchanged. + self._has_thing_type = self._has_column("thing_type") + # ------------------------------------------------------------------ db def _connect(self): try: @@ -117,6 +123,25 @@ def _fetch(self, sql, params=None): if conn is not None: conn.close() + def _has_column(self, column): + """Whether the backing relation exposes ``column``. + + Reads pg_attribute rather than information_schema.columns: the + chemistry collections are backed by materialized views, which + information_schema does not list at all. + """ + try: + rows = self._fetch( + "SELECT 1 FROM pg_attribute " + "WHERE attrelid = to_regclass(%s) AND attname = %s " + "AND attnum > 0 AND NOT attisdropped LIMIT 1", + [self.table, column], + ) + except ProviderConnectionError: + # View may not exist yet (e.g. OpenAPI generation before migrate). + return False + return bool(rows) + # -------------------------------------------------------------- fields def get_fields(self): """Return the parameter-name fields present in the backing view.""" @@ -192,8 +217,11 @@ def locations( bbox=bbox, ) where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + columns = "thing_id, station_name, longitude, latitude" + if self._has_thing_type: + columns += ", thing_type" rows = self._fetch( - f"SELECT DISTINCT thing_id, station_name, longitude, latitude " # noqa: S608 + f"SELECT DISTINCT {columns} " # noqa: S608 (trusted table/columns) f"FROM {self.table}{where} ORDER BY thing_id", params, ) @@ -207,12 +235,18 @@ def locations( "type": "Point", "coordinates": [row["longitude"], row["latitude"]], }, - "properties": {"name": row["station_name"]}, + "properties": self._station_properties(row), } for row in rows ], } + def _station_properties(self, row): + properties = {"name": row["station_name"]} + if self._has_thing_type: + properties["thing_type"] = row["thing_type"] + return properties + def area( self, wkt=None, select_properties=None, datetime_=None, instance=None, **kwargs ): diff --git a/services/materialized_views.py b/services/materialized_views.py index ec1ae7103..9b7e3740e 100644 --- a/services/materialized_views.py +++ b/services/materialized_views.py @@ -15,5 +15,9 @@ "ogc_water_well_summary", "ogc_major_chemistry_results", "ogc_minor_chemistry_wells", + # EDR chemistry coverages (d9e0f1a2b3c4). Same legacy source tables as the + # two pivot views above, at per-result grain. + "ogc_water_chemistry", + "ogc_internal_water_chemistry", "transducer_daily_data", ) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index f64a81306..cc583b074 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -70,10 +70,12 @@ def __exit__(self, exc_type, exc, tb): "REFRESH MATERIALIZED VIEW ogc_water_well_summary", "REFRESH MATERIALIZED VIEW ogc_major_chemistry_results", "REFRESH MATERIALIZED VIEW ogc_minor_chemistry_wells", + "REFRESH MATERIALIZED VIEW ogc_water_chemistry", + "REFRESH MATERIALIZED VIEW ogc_internal_water_chemistry", "REFRESH MATERIALIZED VIEW transducer_daily_data", ] assert commit_called["value"] is True - assert "Refreshed 8 materialized view(s)." in result.output + assert "Refreshed 10 materialized view(s)." in result.output def test_refresh_materialized_views_custom_and_concurrently( @@ -702,10 +704,12 @@ def _write_csv(path: Path, *, well_name: str, notes: str): "Water level accurate to within two hundreths of a foot," f"{notes}" ) - csv_text = textwrap.dedent(f"""\ + csv_text = textwrap.dedent( + f"""\ {header} {row} - """) + """ + ) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" diff --git a/tests/test_edr_provider.py b/tests/test_edr_provider.py new file mode 100644 index 000000000..f7ba87dcc --- /dev/null +++ b/tests/test_edr_provider.py @@ -0,0 +1,32 @@ +"""Unit tests for the EDR provider's optional station metadata. + +The chemistry views (d9e0f1a2b3c4) carry thing_type because they span wells and +springs; ogc_waterlevels does not. The provider detects the column rather than +assuming it, so these cover both shapes without needing a database. +""" + +from core.edr_provider import WaterEDRProvider + + +def _provider(has_thing_type: bool) -> WaterEDRProvider: + # Bypass __init__: it connects to Postgres to read fields and detect + # columns, and neither is what these tests are about. + provider = object.__new__(WaterEDRProvider) + provider._has_thing_type = has_thing_type + return provider + + +def test_station_properties_includes_thing_type_when_the_view_has_it(): + properties = _provider(True)._station_properties( + {"station_name": "NM-28368", "thing_type": "spring"} + ) + + assert properties == {"name": "NM-28368", "thing_type": "spring"} + + +def test_station_properties_omits_thing_type_when_the_view_lacks_it(): + # ogc_waterlevels has no thing_type column, so the row has no such key -- + # reading it unconditionally would raise instead of degrading. + properties = _provider(False)._station_properties({"station_name": "NM-28368"}) + + assert properties == {"name": "NM-28368"} From a102ad480c0e3f3599a50948b4d5d16f285e3b44 Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:58:34 +0000 Subject: [PATCH 050/151] Formatting changes --- tests/test_cli_commands.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index cc583b074..97534a601 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -704,12 +704,10 @@ def _write_csv(path: Path, *, well_name: str, notes: str): "Water level accurate to within two hundreths of a foot," f"{notes}" ) - csv_text = textwrap.dedent( - f"""\ + csv_text = textwrap.dedent(f"""\ {header} {row} - """ - ) + """) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" From b6887b218e91130461b2e9ba9216e5e8da374c05 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:07:47 +0000 Subject: [PATCH 051/151] build(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1 Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 9.0.0 to 10.0.1. - [Commits](https://github.com/astral-sh/setup-uv/compare/v9.0.0...v10.0.1) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/CD_production.yml | 2 +- .github/workflows/CD_staging.yml | 2 +- .github/workflows/CD_testing.yml | 2 +- .github/workflows/data_migrations.yml | 2 +- .github/workflows/forward-merge.yml | 4 ++-- .github/workflows/jira_codex_pr.yml | 2 +- .github/workflows/tests.yml | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 39c5ff6b4..c5e1e8b2d 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -54,7 +54,7 @@ jobs: ref: refs/tags/${{ env.DEPLOY_TAG }} - name: Install uv in container - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v10.0.1 with: version: "latest" diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index c1cbd243d..fa42b00da 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - name: Install uv in container - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v10.0.1 with: version: "latest" diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index 0ed101d15..920ad98c9 100644 --- a/.github/workflows/CD_testing.yml +++ b/.github/workflows/CD_testing.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - name: Install uv in container - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v10.0.1 with: version: "latest" diff --git a/.github/workflows/data_migrations.yml b/.github/workflows/data_migrations.yml index b8af98622..32def6fd7 100644 --- a/.github/workflows/data_migrations.yml +++ b/.github/workflows/data_migrations.yml @@ -72,7 +72,7 @@ jobs: uses: actions/checkout@v7.0.1 - name: Install uv in container - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v10.0.1 with: version: "latest" diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml index ca10accf3..8b36722d2 100644 --- a/.github/workflows/forward-merge.yml +++ b/.github/workflows/forward-merge.yml @@ -103,7 +103,7 @@ jobs: # the lockfile is re-locked (see commit 27751110). Idempotent: no # lockfile change -> no commit. - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v10.0.1 with: enable-cache: true cache-dependency-glob: uv.lock @@ -166,7 +166,7 @@ jobs: # push. Plain push (not force) so an out-of-date checkout fails loudly # instead of clobbering newer hotfix commits. - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v10.0.1 with: enable-cache: true cache-dependency-glob: uv.lock diff --git a/.github/workflows/jira_codex_pr.yml b/.github/workflows/jira_codex_pr.yml index 149dfc60e..f352b7410 100644 --- a/.github/workflows/jira_codex_pr.yml +++ b/.github/workflows/jira_codex_pr.yml @@ -59,7 +59,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Set up uv (with cache) - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v4 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v4 with: enable-cache: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9f8b0fcb0..7aea89d11 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,7 +62,7 @@ jobs: exit 1 - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v10.0.1 with: enable-cache: true cache-dependency-glob: uv.lock @@ -153,7 +153,7 @@ jobs: exit 1 - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v10.0.1 with: enable-cache: true cache-dependency-glob: uv.lock From e7e89ba7f94d38d098c766ca3f24e63ac123bcdc Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 17 Aug 2026 09:33:22 -0700 Subject: [PATCH 052/151] chore(auth): harden Authentik authorization Six related fixes to the authorization layer, plus two defects surfaced while adding coverage for it. Fail fast on a misconfigured bypass. assert_auth_configuration() runs at app creation and refuses to boot when AUTHENTIK_DISABLE_AUTHENTICATION=1 outside MODE=development. The previous guard was per-request and keyed on MODE == "production", so a deploy with MODE unset served every endpoint anonymously and logged nothing. Collapses the two disagreeing reads of that variable (an import-time snapshot governing JWKS, a per-request read governing enforcement) into authentication_disabled(), and makes settings.mode a fresh env read so it no longer depends on which module called load_dotenv() first. Enforce the role hierarchy in code. authenticated() gains any_of=, and the tiers in core/dependencies.py list every group that satisfies them, so Admin satisfies an editor- or viewer-gated route. Previously the documented Admin > Editor > Viewer hierarchy existed nowhere in code and held only as long as whoever provisioned Authentik granted all three tiers; an Admin without the Viewer group was denied on every read. Families stay orthogonal -- general Admin confers nothing in AMP or Lexicon. Rename @public_route to @in_public_schema. It only ever controlled anonymous OpenAPI visibility, but read like an authorization decorator: two /thing routes carried it alongside a viewer_dependency. Removed from those, added to the routes that genuinely have no dependency (/ngwmn/*, polled by the federal NGWMN harvester, and /disclaimer, advertised as terms_of_service by both pygeoapi mounts). Verify the iss claim against AUTHENTIK_URL, accepting both trailing-slash spellings. Drop the dead scope= parameter, which was unused and would have substring-matched had it been used, since the OIDC scope claim is a space-delimited string rather than a list. Share one _decode() helper between the Depends path and the internal-OGC ASGI middleware, so a request decodes its token once instead of twice. Give the JWKS cache a TTL (AUTHENTIK_JWKS_TTL_SECONDS, default 3600) and force one refresh on an unrecognized kid. It was an unbounded lru_cache, so an Authentik key rotation 401'd every request until the process was redeployed. Insufficient groups now returns 403 rather than 401. This matters to OcotilloUI, whose axios-auth-refresh interceptor fires on 401 only: the old status sent an under-permissioned user through a token refresh and retry, and on to a forced logout. Two defects the new tests caught: POST /feedback was fully unauthenticated. It declared `_user=viewer_dependency` -- the dependency alias as a default value rather than a type annotation -- so FastAPI treated _user as a query parameter and never ran the dependency. Anyone could post arbitrary content that the server relays into Jira and Slack under its own credentials. public_openapi() could not see routes added via include_router. Those live inside opaque _IncludedRouter branches rather than being flattened into app.routes, so matching schema paths against app.routes by .path found only endpoints declared directly on the app, and the public schema contained /health alone. It now walks iter_route_contexts(), the same helper get_openapi() uses. tests/test_authorization.py covers all of it: an inventory test over every route's dependency tree against an explicit allowlist of intentionally anonymous routes (authorization is opt-in per endpoint, so nothing else notices an omission), agreement between that set and the anonymous OpenAPI schema, the group-membership logic, the startup guard, and JWKS caching. Co-Authored-By: Claude Opus 5 --- .env.example | 11 ++ CLAUDE.md | 23 +++ api/disclaimer.py | 2 + api/feedback.py | 2 +- api/ngwmn.py | 9 + api/thing.py | 3 - core/app.py | 55 ++++-- core/dependencies.py | 29 ++- core/factory.py | 9 + core/internal_ogc_auth.py | 14 +- core/permissions.py | 265 +++++++++++++++++-------- core/settings.py | 14 +- tests/test_authorization.py | 376 ++++++++++++++++++++++++++++++++++++ 13 files changed, 687 insertions(+), 125 deletions(-) create mode 100644 tests/test_authorization.py diff --git a/.env.example b/.env.example index 2e2bd4556..5fa1ad8ba 100644 --- a/.env.example +++ b/.env.example @@ -80,17 +80,28 @@ MODE=development # ENABLE_PG_CRON=0 # disable authentication (for development only) +# +# Honored ONLY when MODE=development. With any other MODE (including unset or +# "staging"), the app refuses to start -- core.permissions.assert_auth_configuration() +# raises AuthConfigurationError rather than serving every endpoint anonymously. AUTHENTIK_DISABLE_AUTHENTICATION=1 # erase and rebuild the database for step tests REBUILD_DB=1 # authentik +# AUTHENTIK_URL is both the JWKS base and the expected `iss` claim; trailing +# slash optional, both spellings are accepted. AUTHENTIK_URL= AUTHENTIK_CLIENT_ID= AUTHENTIK_AUTHORIZE_URL= AUTHENTIK_TOKEN_URL= +# How long a fetched JWKS document is trusted, in seconds (default 3600). +# An unrecognized `kid` forces one immediate refresh regardless, so this only +# bounds how long a revoked key stays usable. +# AUTHENTIK_JWKS_TTL_SECONDS=3600 + # feedback endpoint (POST /feedback) — bug reports and feature requests JIRA_BASE_URL=https://nmbgmr.atlassian.net diff --git a/CLAUDE.md b/CLAUDE.md index 77cb84105..88802a2a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,8 +155,31 @@ The system uses **Authentik** for OAuth2 authentication with role-based access c - **Editor**: Can modify existing records (includes Viewer permissions) - **Admin**: Can create new records (includes Editor + Viewer permissions) +The hierarchy is enforced in code, via `authenticated(any_of=[...])` group lists — +`Admin` satisfies an editor- or viewer-gated route without needing all three +Authentik groups granted. + **AMP-Specific Roles**: `AMPAdmin`, `AMPEditor`, `AMPViewer` for legacy AMPAPI integration +**Role families are orthogonal**: general `Admin` confers nothing in the AMP or +Lexicon families. Only tiers *within* a family nest. + +**Authorization is opt-in per endpoint** — a `user: _dependency` parameter +in the signature, not a router-level `dependencies=[...]`. Omitting it produces a +fully public endpoint with no error. `tests/test_authorization.py` holds the +allowlist of intentionally anonymous routes and fails on anything else. Note the +annotation must be a *type annotation* (`user: viewer_dependency`), never a +default value (`user=viewer_dependency`) — the latter silently disables the +dependency and FastAPI treats it as a query parameter. + +**Development bypass**: `AUTHENTIK_DISABLE_AUTHENTICATION=1` is honored only when +`MODE=development`. Any other `MODE` (including unset) makes +`assert_auth_configuration()` abort startup. + +**`@in_public_schema`** (`core/app.py`) controls anonymous OpenAPI visibility +only — it grants no access and removes no dependency. Apply it only to routes +that genuinely have none. + ### Database Configuration The application supports two database modes (configured via `DB_DRIVER` in `.env`): diff --git a/api/disclaimer.py b/api/disclaimer.py index 2e94981d1..b0ec7e836 100644 --- a/api/disclaimer.py +++ b/api/disclaimer.py @@ -31,6 +31,7 @@ from fastapi import APIRouter, Query, Request from fastapi.responses import HTMLResponse, JSONResponse +from core.app import in_public_schema from core.disclaimer import ( DISCLAIMER_CONTACT_EMAIL, DISCLAIMER_PARAGRAPHS, @@ -84,6 +85,7 @@ def _render_html() -> str: ) +@in_public_schema @router.get( "/disclaimer", response_class=HTMLResponse, diff --git a/api/feedback.py b/api/feedback.py index 68f632b2f..ce3d3473a 100644 --- a/api/feedback.py +++ b/api/feedback.py @@ -225,7 +225,7 @@ def _build_slack_payload(payload: FeedbackCreate, jira_key: str, jira_url: str) @router.post("", response_model=FeedbackResponse) async def create_feedback( payload: FeedbackCreate, - _user=viewer_dependency, + _user: viewer_dependency, ): jira_base = os.environ["JIRA_BASE_URL"] jira_email = os.environ["JIRA_EMAIL"] diff --git a/api/ngwmn.py b/api/ngwmn.py index 7fc2e1d51..c954fe788 100644 --- a/api/ngwmn.py +++ b/api/ngwmn.py @@ -16,6 +16,7 @@ from fastapi import APIRouter from starlette.responses import Response +from core.app import in_public_schema from core.dependencies import session_dependency from services.ngwmn_helper import ( make_waterlevels_response, @@ -25,7 +26,13 @@ router = APIRouter(prefix="/ngwmn", tags=["NGWMN"]) +# These three routes are intentionally anonymous: the federal NGWMN harvester +# polls them without credentials. @in_public_schema documents that (and lists +# them in /openapi.json) so tests/test_authorization.py can tell an intentional +# public route from an endpoint that simply forgot its `user:` dependency. + +@in_public_schema @router.get( "/waterlevels/{pointid}", summary="Get waterlevels for a given pointid in the NGWMN format", @@ -35,6 +42,7 @@ def read_ngwmn_waterlevels(pointid: str, db: session_dependency): return Response(content=data, media_type="application/xml") +@in_public_schema @router.get( "/wellconstruction/{pointid}", summary="Get wellconstruction for a given pointid in the NGWMN format", @@ -44,6 +52,7 @@ def read_ngwmn_wellconstruction(pointid: str, db: session_dependency): return Response(content=data, media_type="application/xml") +@in_public_schema @router.get( "/lithology/{pointid}", summary="Get lithology for a given pointid in the NGWMN format", diff --git a/api/thing.py b/api/thing.py index baeed59e7..b1176071d 100644 --- a/api/thing.py +++ b/api/thing.py @@ -27,7 +27,6 @@ ) from api.pagination import CustomPage -from core.app import public_route from core.dependencies import ( session_dependency, admin_dependency, @@ -349,7 +348,6 @@ def get_thing_id_links( return paginate(query=sql, conn=session) -@public_route @router.get("/id-link/{link_id}", summary="Get thing links by link ID") def get_thing_id_links( user: viewer_dependency, @@ -362,7 +360,6 @@ def get_thing_id_links( return simple_get_by_id(session, ThingIdLink, link_id) -@public_route @router.get("", summary="Get all things", status_code=HTTP_200_OK) def get_things( user: viewer_dependency, diff --git a/core/app.py b/core/app.py index d14ccecf3..dd392f08b 100644 --- a/core/app.py +++ b/core/app.py @@ -27,6 +27,7 @@ get_swagger_ui_oauth2_redirect_html, ) from fastapi.openapi.utils import get_openapi +from fastapi.routing import iter_route_contexts from sqlalchemy import text from sqlalchemy.orm import Session @@ -123,27 +124,32 @@ def public_openapi(): routes=app.routes, ) - # Keep only operations where the endpoint function is marked public. + # Collect the operations whose endpoint carries @in_public_schema. + # + # This walks iter_route_contexts() rather than app.routes. Routes added + # via app.include_router() are not flattened into app.routes -- they + # live inside opaque _IncludedRouter branches -- so the previous + # `next(r for r in app.routes if r.path == path)` lookup matched + # nothing but the few endpoints declared directly on `app`, and + # silently dropped every decorated router route from the public schema. + # iter_route_contexts() is the same helper get_openapi() itself walks, + # so prefixes resolve identically to the paths in `schema`. + public_operations = set() + for route_context in iter_route_contexts(app.routes): + if not getattr(route_context.endpoint, "_in_public_schema", False): + continue + route_path = route_context.path_format or route_context.path + for route_method in route_context.methods or (): + public_operations.add((route_path, route_method.lower())) + new_paths = {} for path, path_item in schema["paths"].items(): new_methods = {} for method, operation in path_item.items(): - route = next( - ( - r - for r in app.routes - if getattr(r, "path", None) == path - and method.upper() in getattr(r, "methods", set()) - ), - None, - ) - if not route: + if (path, method.lower()) not in public_operations: continue - - endpoint = getattr(route, "endpoint", None) - if getattr(endpoint, "_is_public", False): - operation["security"] = [] - new_methods[method] = operation + operation["security"] = [] + new_methods[method] = operation if new_methods: new_paths[path] = new_methods @@ -224,7 +230,7 @@ async def warmup(): return {"status": "ok"} @app.get("/health", tags=["meta"]) - @public_route + @in_public_schema def health(response: Response, session: Session = Depends(get_db_session)): # Ping the database so a 200 actually proves PostGIS is reachable, not # just that the process is up. Uptime monitors / status pages assert on @@ -248,9 +254,18 @@ def health(response: Response, session: Session = Depends(get_db_session)): return app -def public_route(func): - """Mark a route as public for OpenAPI filtering.""" - setattr(func, "_is_public", True) +def in_public_schema(func): + """Advertise a route in the anonymous OpenAPI schema (/openapi.json). + + Schema visibility only -- this grants no access and removes no dependency. + It was previously named `public_route`, which read like an authorization + decorator; two `/thing` endpoints carried it *and* a `viewer_dependency`, + so the public schema advertised operations that 401 for anonymous callers. + + Apply it only to routes that genuinely have no auth dependency. + tests/test_authorization.py asserts the two sets match exactly. + """ + setattr(func, "_in_public_schema", True) return func diff --git a/core/dependencies.py b/core/dependencies.py index 6372804a9..09e7c3f79 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -34,26 +34,35 @@ Admin, can do everything Editor and Viewer can do + create new objects +That hierarchy is enforced here, by `any_of=` group lists rather than by +Authentik group membership overlap: an Admin-only account satisfies an +editor- or viewer-gated route because "Admin" appears in those lists. Before +this was explicit, `authenticated(permissions=["Viewer"])` required the +literal Viewer group, so the hierarchy held only as long as whoever +provisioned the Authentik groups granted all three tiers to every admin. + +The three families below are deliberately orthogonal -- general `Admin` does +not confer `AMPAdmin` or `LexiconAdmin`. Only tiers *within* a family nest. """ # General Purpose Authentication/Permissions ----------------------------------- -admin_function = authenticated(permissions=["Admin"]) -editor_function = authenticated(permissions=["Editor"]) -viewer_function = authenticated(permissions=["Viewer"]) +admin_function = authenticated(any_of=["Admin"]) +editor_function = authenticated(any_of=["Admin", "Editor"]) +viewer_function = authenticated(any_of=["Admin", "Editor", "Viewer"]) # AMP-Specific Authentication/Permissions -------------------------------------- -amp_admin_function = authenticated(permissions=["AMPAdmin"]) -amp_editor_function = authenticated(permissions=["AMPEditor"]) -amp_viewer_function = authenticated(permissions=["AMPViewer"]) +amp_admin_function = authenticated(any_of=["AMPAdmin"]) +amp_editor_function = authenticated(any_of=["AMPAdmin", "AMPEditor"]) +amp_viewer_function = authenticated(any_of=["AMPAdmin", "AMPEditor", "AMPViewer"]) # Lexicon-Specific Authentication/Permissions ---------------------------------- -lexicon_admin_function = authenticated(permissions=["LexiconAdmin"]) -lexicon_editor_function = authenticated(permissions=["LexiconEditor"]) +lexicon_admin_function = authenticated(any_of=["LexiconAdmin"]) +lexicon_editor_function = authenticated(any_of=["LexiconAdmin", "LexiconEditor"]) # OGC-Internal Authentication/Permissions -------------------------------------- @@ -63,7 +72,9 @@ # Testing-Specific Authentication/Permissions ---------------------------------- -no_permission_function = authenticated(permissions=["NoPermission"]) +# A group nobody is ever granted, so this dependency always 403s. Used to +# assert that group enforcement is actually wired up. +no_permission_function = authenticated(any_of=["NoPermission"]) # Permissions Dependencies ----------------------------------------------------- diff --git a/core/factory.py b/core/factory.py index f85b6f4cf..79a347e73 100644 --- a/core/factory.py +++ b/core/factory.py @@ -40,6 +40,15 @@ def initialize_runtime() -> None: def create_api_app(): initialize_runtime() + + # After initialize_runtime()'s load_dotenv(), so MODE and + # AUTHENTIK_DISABLE_AUTHENTICATION are both resolved. Raises + # AuthConfigurationError -- boot fails loudly rather than serving every + # endpoint anonymously. + from core.permissions import assert_auth_configuration + + assert_auth_configuration() + app = create_base_app() register_api_routes(app) from core.pygeoapi import mount_pygeoapi, mount_pygeoapi_internal diff --git a/core/internal_ogc_auth.py b/core/internal_ogc_auth.py index 8edd767cf..85f1ee3dd 100644 --- a/core/internal_ogc_auth.py +++ b/core/internal_ogc_auth.py @@ -29,7 +29,6 @@ """ import json -import os from starlette.types import ASGIApp, Receive, Scope, Send @@ -77,18 +76,15 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) return - if int(os.environ.get("AUTHENTIK_DISABLE_AUTHENTICATION", 0)): - if settings.mode == "production": + if permissions.authentication_disabled(): + if settings.mode != permissions.BYPASS_ALLOWED_MODE: # HTTPException(424) (what core.permissions.authenticated() # raises for this same misconfiguration) means nothing from # raw ASGI code -- send the response directly so a - # misconfigured production box degrades to "internal mount - # always 424s" rather than crashing the worker. + # misconfigured box degrades to "internal mount always 424s" + # rather than crashing the worker. await _send_json( - send, - 424, - "Authentication is disabled in production mode. Set " - "AUTHENTIK_DISABLE_AUTHENTICATION=0 to enable authentication.", + send, 424, permissions.bypass_misconfiguration_detail() ) return await self.app(scope, receive, send) diff --git a/core/permissions.py b/core/permissions.py index fec27d37f..ad3406f41 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -14,13 +14,13 @@ # limitations under the License. # =============================================================================== import os -from functools import lru_cache -from typing import Optional, List, Union, cast, Callable +import threading +import time +from typing import Optional, List, Sequence, Tuple, Union, cast, Callable import httpx from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, OAuth2AuthorizationCodeBearer -from fastapi.security import OAuth2PasswordBearer from jose import jwt from jose.exceptions import JWTError from jwt.algorithms import RSAAlgorithm @@ -29,33 +29,133 @@ from core.settings import settings -AUTHENTIK_ISSUER = os.environ.get("AUTHENTIK_URL") ALGORITHMS = ["RS256"] -jwks = {} -auth_disabled = int(os.environ.get("AUTHENTIK_DISABLE_AUTHENTICATION", 0)) -if AUTHENTIK_ISSUER and not auth_disabled: - JWKS_URL = f"{AUTHENTIK_ISSUER}jwks/" +# The only MODE in which AUTHENTIK_DISABLE_AUTHENTICATION=1 is honored. +# assert_auth_configuration() refuses to boot anywhere else, so a box with an +# unset or mislabeled MODE can never come up with authentication switched off. +BYPASS_ALLOWED_MODE = "development" -@lru_cache(maxsize=1) -def get_jwks(): - if not AUTHENTIK_ISSUER or auth_disabled: +# How long a fetched JWKS document is trusted. Authentik rotates its signing +# keys; the cache used to be an unbounded lru_cache, so a rotation 401'd every +# request until the process was redeployed. get_public_key() also forces one +# refresh on an unrecognized `kid`, which covers rotations inside the window. +JWKS_TTL_SECONDS = int(os.environ.get("AUTHENTIK_JWKS_TTL_SECONDS", "3600")) + + +def _issuer() -> str: + """Authentik issuer URL, read lazily so it survives late load_dotenv().""" + return (os.environ.get("AUTHENTIK_URL") or "").strip() + + +def _accepted_issuers() -> Tuple[str, ...]: + """Issuer values accepted for the `iss` claim. + + Authentik's issuer is the provider URL, which operators configure with or + without a trailing slash depending on where they copied it from. Accept + both spellings rather than making token validation depend on that. + """ + issuer = _issuer() + if not issuer: + return () + return (issuer.rstrip("/"), issuer.rstrip("/") + "/") + + +def authentication_disabled() -> bool: + """Whether the development authentication bypass is switched on. + + Read fresh from the environment on every call. This used to be an + import-time snapshot (`auth_disabled`) that the per-request check in + authenticated() did not share: flipping the variable after import left + JWKS fetching disabled while token verification stayed live, so every + request failed with "Invalid signing key" instead of either enforcing or + bypassing cleanly. + """ + raw = (os.environ.get("AUTHENTIK_DISABLE_AUTHENTICATION") or "0").strip() + try: + return bool(int(raw)) + except ValueError: + return raw.lower() in {"true", "yes", "on"} + + +class AuthConfigurationError(RuntimeError): + """Raised at startup when the auth bypass is enabled outside development.""" + + +def bypass_misconfiguration_detail() -> str: + return ( + "AUTHENTIK_DISABLE_AUTHENTICATION is enabled but MODE is " + f"{settings.mode or ''!r}. The bypass is only permitted when " + f"MODE={BYPASS_ALLOWED_MODE!r}. Set " + "AUTHENTIK_DISABLE_AUTHENTICATION=0, or set MODE=development." + ) + + +def assert_auth_configuration() -> None: + """Fail fast when the app is configured to serve traffic without auth. + + Called from core.factory.create_api_app() after load_dotenv(). The old + guard was per-request and keyed on `settings.mode == "production"`, so a + deploy with MODE unset served every endpoint anonymously and logged + nothing. Two independent variables had to be right; now a wrong one stops + the process at boot. + """ + if authentication_disabled() and settings.mode != BYPASS_ALLOWED_MODE: + raise AuthConfigurationError(bypass_misconfiguration_detail()) + + +_jwks_lock = threading.Lock() +_jwks_cache: dict = {"payload": None, "fetched_at": 0.0} + + +def reset_jwks_cache() -> None: + """Drop the cached JWKS. Test hook and manual-invalidation escape hatch.""" + with _jwks_lock: + _jwks_cache["payload"] = None + _jwks_cache["fetched_at"] = 0.0 + + +def get_jwks(force_refresh: bool = False) -> dict: + if not _issuer() or authentication_disabled(): return {} - resp = httpx.get(JWKS_URL, timeout=10.0) + if not force_refresh: + with _jwks_lock: + cached = _jwks_cache["payload"] + age = time.monotonic() - _jwks_cache["fetched_at"] + if cached is not None and age < JWKS_TTL_SECONDS: + return cached + + resp = httpx.get(f"{_issuer().rstrip('/')}/jwks/", timeout=10.0) resp.raise_for_status() - return resp.json() + payload = resp.json() + with _jwks_lock: + _jwks_cache["payload"] = payload + _jwks_cache["fetched_at"] = time.monotonic() + return payload -def get_public_key(token): - unverified_header = jwt.get_unverified_header(token) - for key in get_jwks().get("keys", []): - if key["kid"] == unverified_header["kid"]: - return RSAAlgorithm.from_jwk(key) - raise HTTPException(status_code=401, detail="Invalid signing key") +def _find_signing_key(jwks: dict, kid: Optional[str]) -> Optional[dict]: + if not kid: + return None + for key in jwks.get("keys", []): + if key.get("kid") == kid: + return key + return None -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +def get_public_key(token): + kid = jwt.get_unverified_header(token).get("kid") + + key = _find_signing_key(get_jwks(), kid) + if key is None: + # Unknown kid: Authentik may have rotated inside the TTL window. + # Refetch once before rejecting an otherwise valid token. + key = _find_signing_key(get_jwks(force_refresh=True), kid) + if key is None: + raise HTTPException(status_code=401, detail="Invalid signing key") + return RSAAlgorithm.from_jwk(key) TokenType = Union[str, HTTPAuthorizationCredentials] @@ -69,84 +169,93 @@ def get_public_key(token): ) +def authorize_groups( + payload: dict, + require_all: Optional[Sequence[str]] = None, + require_any: Optional[Sequence[str]] = None, +) -> bool: + """Check a decoded token's `groups` claim against a group requirement. + + `require_all` demands every listed group; `require_any` demands at least + one. Role tiers in core/dependencies.py use `require_any` so that an Admin + satisfies an Editor- or Viewer-gated route. The old check was all-of only, + which meant the documented Admin > Editor > Viewer hierarchy existed + nowhere in code -- it worked solely because operators happened to grant + overlapping groups in Authentik, and an Admin without the Viewer group got + a 403 on every read. + """ + groups = payload.get("groups") or [] + if require_all and not all(group in groups for group in require_all): + return False + if require_any and not any(group in groups for group in require_any): + return False + return True + + def authenticated( optional: bool = False, - scope: Optional[List[str]] = None, permissions: Optional[List[str]] = None, + any_of: Optional[List[str]] = None, ): + """Build a FastAPI dependency enforcing a bearer token and group membership. - def _authenicated( + `permissions` requires every listed Authentik group, `any_of` requires at + least one. Returns the decoded token payload on success so endpoints can + read claims; returns True when the development bypass is active. + """ + + def _authenticated( request: Request, response: Response, token: TokenType = Depends(cast(Callable, scheme)), ): - # def _authenicated(request: Request, response: Response): - # def _authenicated(): - """ - A placeholder for the authentication logic. - This function should check if the user is authenticated and has the required permissions. - If `optional` is True, it should allow unauthenticated access. - """ - - if int(os.environ.get("AUTHENTIK_DISABLE_AUTHENTICATION", 0)): - if settings.mode == "production": + if authentication_disabled(): + # assert_auth_configuration() already rejected this combination at + # startup; this is the belt for a variable flipped at runtime. + if settings.mode != BYPASS_ALLOWED_MODE: raise HTTPException( status_code=status.HTTP_424_FAILED_DEPENDENCY, - detail="Authentication is disabled in production mode. Set AUTHENTIK_DISABLE_AUTHENTICATION=0 to enable authentication.", + detail=bypass_misconfiguration_detail(), ) return True - if optional and not token: - return True - - # Here you would typically check the token against your authentication system - # and verify the user's permissions. - - if not token or not verify_token(token, scope, permissions): + if not token: + if optional: + return True raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized" ) - # this is a placeholder for the actual authentication logic - return _get_token_payload(token) if token else None + # Decoded once and reused. The previous flow decoded the JWT twice per + # request: verify_token() decoded to read groups, then the caller + # decoded again to build the return value. + payload = _get_token_payload(token) - return _authenicated + if not authorize_groups(payload, require_all=permissions, require_any=any_of): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden" + ) + return payload -def verify_token( - token: TokenType, scope: Optional[List[str]], permissions: Optional[List[str]] -) -> bool: - """ - Placeholder function to verify the token. - This should contain the logic to check if the token is valid and has the required permissions. - """ - # Implement your token verification logic here + return _authenticated - payload = _get_token_payload(token) - # Optionally check scopes and permissions in payload - if scope: - if not all(s in payload.get("scope", []) for s in scope): - return False - if permissions: - if not all(p in payload.get("groups", []) for p in permissions): - return False - return True +def _decode(token: str) -> dict: + """Verify signature, audience, and issuer, returning the claims.""" + return jwt.decode( + token, + get_public_key(token), + algorithms=ALGORITHMS, + audience=os.environ.get("AUTHENTIK_CLIENT_ID"), # Authentik application + issuer=_accepted_issuers() or None, + ) -def _get_token_payload(token: str = Depends(oauth2_scheme)): +def _get_token_payload(token: str) -> dict: try: - public_key = get_public_key(token) - payload = jwt.decode( - token, - public_key, - algorithms=ALGORITHMS, - audience=os.environ.get( - "AUTHENTIK_CLIENT_ID" - ), # Must match Authentik application - ) - return payload - except JWTError as e: + return _decode(token) + except JWTError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -170,17 +279,11 @@ class TokenInvalid(Exception): def decode_token_payload(token: str) -> dict: - """Same JWT verification as _get_token_payload (get_public_key/JWKS/ - jwt.decode), but raises TokenInvalid instead of HTTPException(401). + """Same JWT verification as _get_token_payload (shared _decode helper), + but raises TokenInvalid instead of HTTPException(401). """ try: - public_key = get_public_key(token) - return jwt.decode( - token, - public_key, - algorithms=ALGORITHMS, - audience=os.environ.get("AUTHENTIK_CLIENT_ID"), - ) + return _decode(token) except (JWTError, HTTPException) as e: raise TokenInvalid(str(e)) from e diff --git a/core/settings.py b/core/settings.py index 95ea93b68..c29c5719c 100644 --- a/core/settings.py +++ b/core/settings.py @@ -30,8 +30,18 @@ def _resolve_version() -> str: class Settings: version = _resolve_version() - def __init__(self): - self.mode = os.getenv("MODE", "") # Default mode + @property + def mode(self) -> str: + """Deployment mode, read fresh from the environment on every access. + + This used to be snapshotted in __init__. Settings() is instantiated + while core.app is imported, which happens before core.factory calls + load_dotenv() -- so whether MODE was visible depended on which module + happened to call load_dotenv() first. Reading it lazily makes the + value independent of import order, which matters because + core.permissions gates the authentication bypass on it. + """ + return os.getenv("MODE", "") def get_enum(self, name: str): if name == "MODE": diff --git a/tests/test_authorization.py b/tests/test_authorization.py new file mode 100644 index 000000000..97608ae9c --- /dev/null +++ b/tests/test_authorization.py @@ -0,0 +1,376 @@ +# =============================================================================== +# Copyright 2026 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +"""Authorization wiring tests. + +CI runs with AUTHENTIK_DISABLE_AUTHENTICATION=1, so no test here can exercise +a real Authentik token. These tests cover the parts that are verifiable +without one: + +* which routes have no authentication dependency at all (an inventory test -- + authorization is opt-in per endpoint, so a forgotten `user:` parameter + silently publishes an endpoint and nothing else would catch it), +* that the anonymous OpenAPI schema advertises exactly those routes, +* the pure group-membership logic behind the role tiers, +* the startup guard on the development bypass. +""" + +import pytest +from fastapi.routing import iter_route_contexts + +from core import dependencies, permissions +from tests import client + +# Routes that are allowed to have no authentication dependency. Adding an entry +# here is a deliberate decision to publish an endpoint anonymously -- it is not +# a formality to satisfy the test. +# +# /health, /_ah/warmup uptime monitors and App Engine warmup +# /docs-auth, /openapi-auth Swagger UI and schema, no data +# /disclaimer advertised as terms_of_service by both pygeoapi +# mounts, so OGC clients fetch it uncredentialed +# /ngwmn/* polled by the federal NGWMN harvester +# +# Not listed, because they never reach this scan: +# /openapi.json, /docs, /redoc are bare Starlette routes with no dependant. +# /ogcapi is a Mount -- anonymous by design; /ogcapi-internal is gated by +# core.internal_ogc_auth.InternalOGCAuthMiddleware, outside Depends(). +EXPECTED_ANONYMOUS_ROUTES = { + ("GET", "/health"), + ("GET", "/_ah/warmup"), + ("GET", "/docs-auth"), + ("GET", "/docs-auth/oauth2-redirect"), + ("GET", "/openapi-auth.json"), + ("GET", "/disclaimer"), + ("GET", "/ngwmn/waterlevels/{pointid}"), + ("GET", "/ngwmn/wellconstruction/{pointid}"), + ("GET", "/ngwmn/lithology/{pointid}"), +} + +# Every dependency callable built by core.permissions.authenticated(). +AUTH_DEPENDENCY_CALLABLES = frozenset( + { + dependencies.admin_function, + dependencies.editor_function, + dependencies.viewer_function, + dependencies.amp_admin_function, + dependencies.amp_editor_function, + dependencies.amp_viewer_function, + dependencies.lexicon_admin_function, + dependencies.lexicon_editor_function, + dependencies.no_permission_function, + } +) + + +def _has_auth_dependency(dependant) -> bool: + """Walk a route's dependency tree looking for an auth dependency.""" + if dependant.call in AUTH_DEPENDENCY_CALLABLES: + return True + return any(_has_auth_dependency(sub) for sub in dependant.dependencies) + + +def _anonymous_routes() -> set: + """Every (method, path) with no authentication dependency. + + Walks iter_route_contexts() rather than app.routes: routes registered via + include_router() are not flattened into app.routes in this FastAPI version, + so a plain scan would see only the endpoints declared directly on `app` and + this test would pass while reporting on ~5 of ~130 routes. + """ + found = set() + for route_context in iter_route_contexts(client.app.routes): + dependant = getattr(route_context, "dependant", None) + if dependant is None: + # Not an APIRoute (Mounts, bare Starlette routes such as /docs). + continue + if _has_auth_dependency(dependant): + continue + path = route_context.path_format or route_context.path + for method in route_context.methods or (): + if method in ("HEAD", "OPTIONS"): + continue + found.add((method, path)) + return found + + +def test_no_unintended_anonymous_routes(): + """Every route without an auth dependency is one we chose to publish. + + Authorization is declared per endpoint as a `user: _dependency` + parameter, not at the router level, so omitting it produces a fully public + endpoint with no error anywhere. This test is the only thing that notices. + """ + unexpected = _anonymous_routes() - EXPECTED_ANONYMOUS_ROUTES + assert not unexpected, ( + "These routes have no authentication dependency. Add a `user: " + "_dependency` parameter, or add them to " + f"EXPECTED_ANONYMOUS_ROUTES if they are meant to be public: " + f"{sorted(unexpected)}" + ) + + +def test_expected_anonymous_routes_still_exist(): + """Keeps EXPECTED_ANONYMOUS_ROUTES from rotting into a stale allowlist.""" + stale = EXPECTED_ANONYMOUS_ROUTES - _anonymous_routes() + assert not stale, ( + "EXPECTED_ANONYMOUS_ROUTES lists routes that no longer exist or now " + f"require authentication -- remove them: {sorted(stale)}" + ) + + +def test_public_schema_advertises_only_anonymous_routes(): + """@in_public_schema must not advertise an authenticated operation. + + Two /thing routes used to carry the decorator (then named @public_route) + alongside a viewer_dependency, so the anonymous schema described endpoints + that 401 for anonymous callers. + """ + schema = client.get("/openapi.json").json() + advertised = { + (method.upper(), path) + for path, item in schema["paths"].items() + for method in item + } + assert advertised <= EXPECTED_ANONYMOUS_ROUTES, ( + "The anonymous OpenAPI schema advertises routes that require " + "authentication. Remove @in_public_schema from them: " + f"{sorted(advertised - EXPECTED_ANONYMOUS_ROUTES)}" + ) + + +# Group membership logic ------------------------------------------------------- + + +@pytest.mark.parametrize( + "groups, expected", + [ + (["Admin"], True), + (["Editor"], True), + (["Viewer"], True), + (["AMPAdmin"], False), + ([], False), + ], +) +def test_admin_satisfies_viewer_tier(groups, expected): + """Admin > Editor > Viewer is enforced in code, not by Authentik overlap.""" + assert ( + permissions.authorize_groups( + {"groups": groups}, require_any=["Admin", "Editor", "Viewer"] + ) + is expected + ) + + +@pytest.mark.parametrize( + "groups, expected", + [ + (["Admin"], True), + (["Editor"], False), + (["Viewer"], False), + ], +) +def test_admin_tier_does_not_accept_lower_roles(groups, expected): + assert ( + permissions.authorize_groups({"groups": groups}, require_any=["Admin"]) + is expected + ) + + +def test_role_families_stay_orthogonal(): + """General Admin confers nothing in the AMP or Lexicon families.""" + payload = {"groups": ["Admin"]} + assert not permissions.authorize_groups( + payload, require_any=["AMPAdmin", "AMPEditor", "AMPViewer"] + ) + assert not permissions.authorize_groups( + payload, require_any=["LexiconAdmin", "LexiconEditor"] + ) + + +def test_require_all_demands_every_group(): + assert permissions.authorize_groups( + {"groups": ["Admin", "AMPAdmin"]}, require_all=["Admin", "AMPAdmin"] + ) + assert not permissions.authorize_groups( + {"groups": ["Admin"]}, require_all=["Admin", "AMPAdmin"] + ) + + +def test_missing_groups_claim_denies(): + """A token with no `groups` claim must not satisfy a role requirement.""" + assert not permissions.authorize_groups({}, require_any=["Viewer"]) + assert not permissions.authorize_groups({"groups": None}, require_any=["Viewer"]) + + +# Bypass configuration guard --------------------------------------------------- + + +@pytest.fixture +def auth_env(monkeypatch): + """Set MODE and AUTHENTIK_DISABLE_AUTHENTICATION for one test.""" + + def _set(mode, disabled): + monkeypatch.setenv("MODE", mode) + monkeypatch.setenv("AUTHENTIK_DISABLE_AUTHENTICATION", disabled) + + return _set + + +@pytest.mark.parametrize("mode", ["production", "staging", "", "Development"]) +def test_bypass_outside_development_refuses_to_boot(auth_env, mode): + """The bypass is honored in development only. + + The old guard only rejected MODE=="production", so a deploy with MODE + unset served every endpoint anonymously and said nothing about it. + """ + auth_env(mode, "1") + with pytest.raises(permissions.AuthConfigurationError) as exc: + permissions.assert_auth_configuration() + assert "AUTHENTIK_DISABLE_AUTHENTICATION" in str(exc.value) + + +def test_bypass_allowed_in_development(auth_env): + auth_env("development", "1") + permissions.assert_auth_configuration() + + +@pytest.mark.parametrize("mode", ["production", "staging", "", "development"]) +def test_any_mode_boots_with_auth_enabled(auth_env, mode): + auth_env(mode, "0") + permissions.assert_auth_configuration() + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("1", True), + ("0", False), + ("", False), + ("true", True), + ("TRUE", True), + ("on", True), + ("no", False), + ("garbage", False), + ], +) +def test_authentication_disabled_parsing(monkeypatch, raw, expected): + """A non-numeric value must not crash the guard into a bypass.""" + monkeypatch.setenv("AUTHENTIK_DISABLE_AUTHENTICATION", raw) + assert permissions.authentication_disabled() is expected + + +def test_authentication_disabled_defaults_to_enforcing(monkeypatch): + monkeypatch.delenv("AUTHENTIK_DISABLE_AUTHENTICATION", raising=False) + assert permissions.authentication_disabled() is False + + +def test_mode_is_read_fresh_from_environment(monkeypatch): + """settings.mode used to be an import-time snapshot, so whether MODE was + visible depended on which module called load_dotenv() first.""" + from core.settings import settings + + monkeypatch.setenv("MODE", "sentinel-mode") + assert settings.mode == "sentinel-mode" + + +# JWKS caching ----------------------------------------------------------------- + + +def test_jwks_cache_expires(monkeypatch): + """A TTL'd cache, so an Authentik key rotation does not require a redeploy. + + The cache was an unbounded lru_cache: once a rotation invalidated the + cached keys every request 401'd with "Invalid signing key" until the + process restarted. + """ + permissions.reset_jwks_cache() + monkeypatch.setenv("AUTHENTIK_URL", "https://authentik.example/application/o/x/") + monkeypatch.setenv("AUTHENTIK_DISABLE_AUTHENTICATION", "0") + + fetches = [] + + class _Resp: + def raise_for_status(self): + pass + + def json(self): + return {"keys": [{"kid": f"k{len(fetches)}"}]} + + def _fake_get(url, **kwargs): + fetches.append(url) + return _Resp() + + monkeypatch.setattr(permissions.httpx, "get", _fake_get) + + clock = {"now": 1000.0} + monkeypatch.setattr(permissions.time, "monotonic", lambda: clock["now"]) + + permissions.get_jwks() + permissions.get_jwks() + assert len(fetches) == 1, "within the TTL the cached document is reused" + assert fetches[0] == "https://authentik.example/application/o/x/jwks/" + + clock["now"] += permissions.JWKS_TTL_SECONDS + 1 + permissions.get_jwks() + assert len(fetches) == 2, "past the TTL the document is refetched" + + permissions.reset_jwks_cache() + + +def test_jwks_not_fetched_when_bypass_active(monkeypatch): + permissions.reset_jwks_cache() + monkeypatch.setenv("AUTHENTIK_URL", "https://authentik.example/application/o/x/") + monkeypatch.setenv("AUTHENTIK_DISABLE_AUTHENTICATION", "1") + + def _explode(*args, **kwargs): + raise AssertionError("JWKS must not be fetched while auth is bypassed") + + monkeypatch.setattr(permissions.httpx, "get", _explode) + assert permissions.get_jwks() == {} + + +def test_accepted_issuers_covers_both_slash_spellings(monkeypatch): + """The `iss` claim is now verified; operators configure AUTHENTIK_URL with + and without a trailing slash, so both spellings must be accepted.""" + monkeypatch.setenv("AUTHENTIK_URL", "https://authentik.example/application/o/x/") + assert set(permissions._accepted_issuers()) == { + "https://authentik.example/application/o/x", + "https://authentik.example/application/o/x/", + } + + monkeypatch.setenv("AUTHENTIK_URL", "https://authentik.example/application/o/x") + assert set(permissions._accepted_issuers()) == { + "https://authentik.example/application/o/x", + "https://authentik.example/application/o/x/", + } + + +def test_accepted_issuers_empty_when_unconfigured(monkeypatch): + """Empty tuple, which _decode() passes to jose as issuer=None.""" + monkeypatch.delenv("AUTHENTIK_URL", raising=False) + assert permissions._accepted_issuers() == () + + +def test_dead_scope_parameter_is_gone(): + """`scope=` was never used and was wrong if it had been: the OIDC scope + claim is a space-delimited string, so `s in payload["scope"]` was substring + matching.""" + import inspect + + assert "scope" not in inspect.signature(permissions.authenticated).parameters + + +# ============= EOF ============================================= From fde4c3395141978addb4ac57a54a0e7c93212ff3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:48:34 +0000 Subject: [PATCH 053/151] build(deps): bump sqlparse from 0.5.5 to 0.6.0 (#831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.5.5 to 0.6.0.
    Changelog

    Sourced from sqlparse's changelog.

    Release 0.6.0 (Aug 13, 2026)

    Notable Changes

    • Drop support for Python 3.8 and 3.9. Python 3.10+ is now required.
    • IMPORTANT: Fixes a potential denial of service attack (DOS) in the lexer, which consumed CPU quadratically on statements containing many unclosed dollar-quoted literals or multiline comments (CVE-2026-59893). See the security advisory for details: https://github.com/andialbrecht/sqlparse/security/advisories/GHSA-prg7-hcfm-mfcr The vulnerability was discovered by EQSTLab, min8282 and 7thpark. Thanks for reporting!
    • IMPORTANT: Fixes a potential denial of service attack (DOS) when grouping deeply nested or very wide statements. Building a token group re-read the whole group on every step, so a small statement could keep a worker busy for a long time (CVE-2026-54284, pr848 by alhudz and tonghuaroot).
    • IMPORTANT: Fixes a potential denial of service attack (DOS) in format(sql, reindent=True), which consumed CPU quadratically on long lists of tuples. See the security advisory for details: https://github.com/andialbrecht/sqlparse/security/advisories/GHSA-cfqr-cjx5-5jcm
    • IMPORTANT: Fixes a potential denial of service attack (DOS) on statements that consist only of comments (CVE-2026-71491). See the security advisory for details: https://github.com/andialbrecht/sqlparse/security/advisories/GHSA-f2ff-p2ww-7p4p The vulnerability was discovered by @​sanktjodel. Thanks for reporting!
    • IMPORTANT: Backslashes are now escaped in the python and php output formats. Without escaping, SQL containing a backslash could break out of the generated string literal (CVE-2026-59894). See the security advisory for details: https://github.com/andialbrecht/sqlparse/security/advisories/GHSA-3496-9g83-7v6x The vulnerability was discovered by @​7thParkk. Thanks for reporting!

    Enhancements

    • Modernize type annotations in top-level API functions using PEP 585 and PEP 604 syntax.
    • END FOR and END CASE are now recognized as keywords.

    Bug Fixes

    • Statement splitting was rewritten on a stack-based architecture. This fixes splitting of statements with nested BEGIN ... END blocks (issue845).
    • Fix function grouping being skipped in CREATE TABLE ... AS SELECT statements when the as keyword is lowercase (pr867 by Osamaali313).
    • Recognize ROW_FORMAT as a keyword so that ALTER TABLE ... ROW_FORMAT=... no longer merges the table name and the option into a single identifier (issue773, pr860 by apoorvdarshan).
    • Recognize MATERIALIZED as a keyword so it is parsed and formatted consistently in CREATE MATERIALIZED VIEW statements (issue752, pr854 by

    ... (truncated)

    Commits
    • 2f40da9 Update version number.
    • 5753f15 Align the changelog entries for this release with previous ones
    • b9588d9 Unify the benchmark scripts on a shared harness
    • 519e416 Pair comment/dollar-quote delimiters at the lexer position
    • a51df6d Measure reindent offsets backwards to avoid quadratic CPU use
    • 73d9ccd Update CHANGELOG
    • d1d8060 Fix uncontrolled CPU consumption (ReDoS) in the lexer's handling of dollar-qu...
    • ef2012a Fix quadratic DoS in group_comments (GHSA-f2ff-p2ww-7p4p)
    • 26112dd Update Changelog.
    • 53ff44b Escape backslashes in output formatters.
    • Additional commits viewable in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=sqlparse&package-manager=uv&previous-version=0.5.5&new-version=0.6.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 this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/DataIntegrationGroup/OcotilloAPI/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- requirements.txt | 6 +++--- uv.lock | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 832eb4532..ff715e38a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,7 @@ dependencies = [ "sqlalchemy-continuum==1.7.0", "sqlalchemy-searchable==2.1.0", "sqlalchemy-utils==0.42.1", - "sqlparse>=0.5.5", + "sqlparse>=0.6.0", "starlette==1.4.1", "typer==0.27.1", "typing-extensions==4.16.0", diff --git a/requirements.txt b/requirements.txt index d7f6222ef..a5c05c2d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1995,9 +1995,9 @@ sqlalchemy-utils==0.42.1 \ # via # ocotilloapi # sqlalchemy-searchable -sqlparse==0.5.5 \ - --hash=sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba \ - --hash=sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e +sqlparse==0.6.0 \ + --hash=sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9 \ + --hash=sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f # via ocotilloapi starlette==1.4.1 \ --hash=sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19 \ diff --git a/uv.lock b/uv.lock index a9f1d2cb0..4966d646c 100644 --- a/uv.lock +++ b/uv.lock @@ -1787,7 +1787,7 @@ requires-dist = [ { name = "sqlalchemy-continuum", specifier = "==1.7.0" }, { name = "sqlalchemy-searchable", specifier = "==2.1.0" }, { name = "sqlalchemy-utils", specifier = "==0.42.1" }, - { name = "sqlparse", specifier = ">=0.5.5" }, + { name = "sqlparse", specifier = ">=0.6.0" }, { name = "starlette", specifier = "==1.4.1" }, { name = "typer", specifier = "==0.27.1" }, { name = "typing-extensions", specifier = "==4.16.0" }, @@ -3044,11 +3044,11 @@ wheels = [ [[package]] name = "sqlparse" -version = "0.5.5" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, + { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" }, ] [[package]] From ae195261a8982fe5effe45a14a00f0e3bb9fa41a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:06:42 +0000 Subject: [PATCH 054/151] build(deps): bump the uv-non-major group across 1 directory with 15 updates (#832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the uv-non-major group with 15 updates in the / directory: | Package | From | To | | --- | --- | --- | | [alembic](https://github.com/sqlalchemy/alembic) | `1.19.0` | `1.19.1` | | [charset-normalizer](https://github.com/jawah/charset_normalizer) | `3.4.9` | `3.5.0` | | [greenlet](https://github.com/python-greenlet/greenlet) | `3.5.4` | `3.5.5` | | [numpy](https://github.com/numpy/numpy) | `2.5.1` | `2.5.2` | | [phonenumbers](https://github.com/daviddrysdale/python-phonenumbers) | `9.0.36` | `9.0.37` | | [scramp](https://github.com/tlocke/scramp) | `1.4.16` | `1.4.17` | | [sentry-sdk[fastapi]](https://github.com/getsentry/sentry-python) | `2.66.1` | `2.68.0` | | [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) | `2.0.51` | `2.0.52` | | [starlette](https://github.com/Kludex/starlette) | `1.4.1` | `1.6.0` | | [typing-inspection](https://github.com/pydantic/typing-inspection) | `0.4.2` | `0.4.4` | | [uvicorn](https://github.com/Kludex/uvicorn) | `0.52.1` | `0.52.3` | | [pre-commit](https://github.com/pre-commit/pre-commit) | `4.6.1` | `4.6.2` | | [filelock](https://github.com/tox-dev/py-filelock) | `3.32.2` | `3.32.3` | | [rasterio](https://github.com/rasterio/rasterio) | `1.5.0` | `1.5.1` | | [sentry-sdk](https://github.com/getsentry/sentry-python) | `2.66.1` | `2.68.0` | Updates `alembic` from 1.19.0 to 1.19.1
    Release notes

    Sourced from alembic's releases.

    1.19.1

    Released: August 8, 2026

    bug

    • [bug] [autogenerate] Fixed bug in the check constraint detection implemented in #508 that failed to take into account column bound check constraints, leading to wrong autogenerate detections.

      References: #1842

    Commits

    Updates `charset-normalizer` from 3.4.9 to 3.5.0
    Release notes

    Sourced from charset-normalizer's releases.

    Version 3.5.0

    3.5.0 (2026-08-12)

    Added

    • Explicit support for Python 3.15

    Fixed

    • Comparing a CharsetMatch to a non-alias encoding strings (#773)
    • Return 0.0 CharsetMatch.multi_byte_usage for empty payloads instead of crashing (#774)
    • A file with both a charset declaration and BOM/SIG did not verify first the BOM/SIG charset.
    • iso2022* cases misdetected due to a flaw in our multibyte chunking logic.

    Changed

    • Replaced the optional mypyc build with Cython extensions while retaining the pure Python fallback. The previous engine (mypyc) started to hit rough limit around the optimization of our noise/coherence detector while Cython allows us to steer the engine toward the right generated optimized sources. This change SHOULD not impact bundler (e.g. Pyinstaller) as the module are immediately discoverable (i.e. not hidden import like mypyc did). Moreover, a long wished distribution is the abi3 wheels, this will allow us to no longer rush each year when a new Python interpreter is released. We still distribute the interpreter specific wheels for faster performance.
    • Applied micro-optimization on several utils.
    • CharsetMatches no longer sort on each match insertion.

    Misc

    • Removed an old performance optimization attempt in apy.py (success_fast_tracked+payload_result_cache).
    Changelog

    Sourced from charset-normalizer's changelog.

    3.5.0 (2026-08-12)

    Added

    • Explicit support for Python 3.15

    Fixed

    • Comparing a CharsetMatch to a non-alias encoding strings (#773)
    • Return 0.0 CharsetMatch.multi_byte_usage for empty payloads instead of crashing (#774)
    • A file with both a charset declaration and BOM/SIG did not verify first the BOM/SIG charset.
    • iso2022* cases misdetected due to a flaw in our multibyte chunking logic.

    Changed

    • Replaced the optional mypyc build with Cython extensions while retaining the pure Python fallback. The previous engine (mypyc) started to hit rough limit around the optimization of our noise/coherence detector while Cython allows us to steer the engine toward the right generated optimized sources. This change SHOULD not impact bundler (e.g. Pyinstaller) as the module are immediately discoverable (i.e. not hidden import like mypyc did). Moreover, a long wished distribution is the abi3 wheels, this will allow us to no longer rush each year when a new Python interpreter is released. We still distribute the interpreter specific wheels for faster performance.
    • Applied micro-optimization on several utils.
    • CharsetMatches no longer sort on each match insertion.

    Misc

    • Removed an old performance optimization attempt in apy.py (success_fast_tracked+payload_result_cache).
    Commits
    • 3325d87 Merge pull request #792 from jawah/update-cibuildwheel-action
    • 77203b1 chore: reformat noxfile.py
    • 8561c22 chore(deps): bump github/codeql-action/upload-sarif (#787)
    • 25248df chore(deps): bump actions/setup-python from 6.3.0 to 7.0.0 (#789)
    • 3eaaf3e chore: enable cp315t in ci
    • fbe9fc4 chore: update cibuildwheel for py315 by default
    • 5c7b82a chore: add emscripten classifier
    • 7d30c21 chore: skip pyodide tests cibw
    • 417d66f Merge pull request #791 from jawah/patch-1
    • 130afd5 chore: perf script initial warm with big5 dummy content
    • Additional commits viewable in compare view

    Updates `greenlet` from 3.5.4 to 3.5.5
    Changelog

    Sourced from greenlet's changelog.

    3.5.5 (2026-08-10)

    • Link the C++ runtime statically into the Windows wheels again, as the Appveyor builds did through 3.3.0. Since 3.3.1 _greenlet.pyd imported MSVCP140.dll, which no Windows CPython distribution ships, so importing greenlet failed on machines without the Visual C++ redistributable. See issue 525 <https://github.com/python-greenlet/greenlet/issues/525>_. Issue and pull request by Daniel Sticker.

    .. note:: Binary 3.15 wheels are now built with 3.15.0rc1. This should be compatible with future 3.15 releases and is believed compatible with 3.15b4 as well (but not earlier versions).

    Commits
    • ddb1453 Preparing release 3.5.5
    • 7de515c Update CHANGES: Credit for issue 525 and note about 3.15 binary wheels [skip ci]
    • f3746aa Merge pull request #526 from stickerdaniel/windows-static-runtime
    • f1ce3ca Restore static linking of the C++ runtime for Windows wheels
    • b6690a5 Merge pull request #523 from python-greenlet/dependabot/github_actions/github...
    • 726cc38 Bump the github-actions group with 3 updates
    • e5c5f4c Merge pull request #522 from ddorian/c-stack-refs-test-detection
    • be55a59 Check that the suspended greenlet is what keeps the class alive
    • e9c01dd Back to development: 3.5.5
    • See full diff in compare view

    Updates `numpy` from 2.5.1 to 2.5.2
    Release notes

    Sourced from numpy's releases.

    v2.5.2 (Aug 9, 2026)

    NumPy 2.5.2 Release Notes

    The NumPy 2.5.2 is a patch release that fixes bugs discovered after the 2.5.1 release. The big news is that it includes wheels for the newly released Python 3.15.0rc1.

    This release supports Python versions 3.12-3.15

    C API changes

    PyArray_StringDTypeObject is opaque under the abi3t stable ABI

    The PyArray_StringDTypeObject was accidentally exposed in NumPy 2.5 when targeting the free-threading-compatible stable ABI (Py_TARGET_ABI3T). PyArray_StringDTypeObject is now an opaque struct: extensions compiled that way cannot access its fields, since the struct layout depends on the size of the object header. Any code that accessed PyArray_StringDTypeObject fields in an abi3t build would have crashed, so we are making this API change in a bugfix release.

    The NpyString allocator API remains usable by passing the descriptor object pointer, e.g. NpyString_acquire_allocator((PyArray_StringDTypeObject *)descr).

    (gh-31771)

    Contributors

    A total of 16 people contributed to this release. People with a "+" by their names contributed a patch for the first time.

    • Abhijeetsingh Meena +
    • Charalampos Stratakis
    • Charles Harris
    • Chris Ninham +
    • David Woods
    • Geonho +
    • Gopu Yeshwanth Reddy +
    • Iason Krommydas
    • Ijtihed Kilani
    • Jelle Zijlstra +
    • Joren Hammudoglu
    • Kumar Aditya
    • Mike Boyle
    • Nathan Goldbaum
    • Raghuveer Devulapalli
    • Sebastian Berg

    ... (truncated)

    Commits
    • 48fecee REL: Prepare for the NumPy 2.5.2 release (#32226)
    • ecf599c Merge pull request #32221 from charris/backport-32151
    • 3c7ac97 Merge pull request #32220 from charris/backport-32205
    • 23b30f4 BUG: avoid segfaults when legacy copyswap slot is not defined (#32151)
    • 4964ca8 TYP: isclose shape-typing fix for 2d array-likes (#32205)
    • c37ed94 MAINT: Skip limited_api tests on some platforms. (#32214)
    • 5cfd73b Merge pull request #32206 from charris/update-cibuildwheel
    • d8262bc MAINT: Update cibuildwheel to v4.2.0
    • 988d94d Merge pull request #32158 from charris/backport-32133
    • b2e4f97 BUG: avoid possible stack overflow in arraydescr_dealloc (#32133)
    • Additional commits viewable in compare view

    Updates `phonenumbers` from 9.0.36 to 9.0.37
    Commits

    Updates `scramp` from 1.4.16 to 1.4.17
    Commits

    Updates `sentry-sdk[fastapi]` from 2.66.1 to 2.68.0
    Release notes

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

    2.68.0

    Important

    • We're making enable_logs and enable_metrics no-op with this release (#7177), and they'll be dropped in the next major.

      Previously, enable_logs also controlled automatic logs collection from the logging and Loguru integrations. These integrations now get an integration-level capture_sentry_logs boolean option to allow for more control over the auto-collection. These options are False by default, i.e., nothing is auto-collected without your explicit opt-in.

      Action Needed

      If you had enable_logs set to True:

      • If you were using the sentry_sdk.logger.X API, no action necessary, the API will just work.
      • If you were auto-collecting logs from either LoggingIntegration or LoguruIntegration, the auto-collection will be turned off in this release. You can switch auto-collection on explicitly with:
      import sentry_sdk
      from sentry_sdk.integrations.logging import LoggingIntegration
      from sentry_sdk.integrations.loguru import LoguruIntegration
      

      sentry_sdk.init( integrations=[ LoggingIntegration(capture_sentry_logs=True), LoguruIntegration(capture_sentry_logs=True), ], )

      If you had enable_logs set to False:

      • If you were using it to gate usages of the sentry_sdk.logger.X API, you'll need to remove the calls entirely or define a before_send_log callback to filter out unwanted logs.

      If you has enable_metrics set to False:

      • Any metrics emitted using the metrics API will be emitted. You'll need to drop them in a before_send_metric or remove the calls to the API.

      Why We're Doing This

      We recognize this is a disruptive change for some folks and want to make it clear this is a one-off. We're removing the options because they were an unnecessary hurdle that one had to jump through to be able to use logs and metrics, and it was confusing why the logging API would not just work on its own. On the other hand, we wanted to give you more fine-grained control over automatic collection.

    New Features ✨

    Other

    Bug Fixes 🐛

    Internal Changes 🔧

    ... (truncated)

    Changelog

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

    2.68.0

    Important

    • We're making enable_logs and enable_metrics no-op with this release (#7177), and they'll be dropped in the next major.

      Previously, enable_logs also controlled automatic logs collection from the logging and Loguru integrations. These integrations now get an integration-level capture_sentry_logs boolean option to allow for more control over the auto-collection. These options are False by default, i.e., nothing is auto-collected without your explicit opt-in.

      Action Needed

      If you had enable_logs set to True:

      • If you were using the sentry_sdk.logger.X API, no action necessary, the API will just work.
      • If you were auto-collecting logs from either LoggingIntegration or LoguruIntegration, the auto-collection will be turned off in this release. You can switch auto-collection on explicitly with:
      import sentry_sdk
      from sentry_sdk.integrations.logging import LoggingIntegration
      from sentry_sdk.integrations.loguru import LoguruIntegration
      

      sentry_sdk.init( integrations=[ LoggingIntegration(capture_sentry_logs=True), LoguruIntegration(capture_sentry_logs=True), ], )

      If you had enable_logs set to False:

      • If you were using it to gate usages of the sentry_sdk.logger.X API, you'll need to remove the calls entirely or define a before_send_log callback to filter out unwanted logs.

      If you has enable_metrics set to False:

      • Any metrics emitted using the metrics API will be emitted. You'll need to drop them in a before_send_metric or remove the calls to the API.

      Why We're Doing This

      We recognize this is a disruptive change for some folks and want to make it clear this is a one-off. We're removing the options because they were an unnecessary hurdle that one had to jump through to be able to use logs and metrics, and it was confusing why the logging API would not just work on its own. On the other hand, we wanted to give you more fine-grained control over automatic collection.

    New Features ✨

    Other

    Bug Fixes 🐛

    ... (truncated)

    Commits
    • c819e66 Update README
    • 90446df Update CHANGELOG.md
    • cb2499c release: 2.68.0
    • 1c3b50d ref: Flush trace buckets when segment spans finish (#7170)
    • c200bdf test(django): Remove dead code and stale markers from the Django test suite (...
    • 4e4ea83 ref(boto3): Move crumbs to integration (#7165)
    • 0f0cd1f ref(stdlib): Move crumbs to integration (#7161)
    • 2fef9bc chore: Make enable_logs, enable_metrics no-op (#7177)
    • 8177739 ref(httpx,httpx2): Move crumbs to integrations (#7149)
    • e4d7398 ref(pyreqwest): Move crumbs to integration (#7148)
    • Additional commits viewable in compare view

    Updates `sqlalchemy` from 2.0.51 to 2.0.52
    Release notes

    Sourced from sqlalchemy's releases.

    2.0.52

    Released: August 11, 2026

    platform

    • [platform] [bug] Python 3.15 support has been added and tested, including minimal changes for full compatibility.

      References: #13477

    orm

    • [orm] [bug] Fixed a result-column misalignment bug in ORM-enabled UPDATE statements where synchronize_session="fetch" is in use, either explicitly or because the statement uses constructs such as CTEs that implicitly select for it. Columns in rows returned by .returning() could be returned under incorrect keys (e.g. row[SomeClass.a] returning the value of a different column), a problem most likely to manifest under concurrent workloads. ORM DELETE statements were not affected.

      References: #13439

    • [orm] [bug] Fixed bug where a failed _orm.Session.bulk_insert_mappings(), _orm.Session.bulk_update_mappings() or _orm.Session.bulk_save_objects() call could leave the _orm.Session permanently in a "flushing" state, such as when the transaction could not be begun because a previous flush had left it needing a rollback. Unlike _orm.Session.flush(), the bulk methods set the internal flushing flag and began the transaction outside of the try/finally block that resets it, so that neither _orm.Session.rollback() nor _orm.Session.close() would clear it, and every subsequent flush would raise InvalidRequestError: Session is already flushing. Pull request courtesy Hamody We.

      References: #13485

    • [orm] [bug] Fixed issue where unpickling an ORM object that were loaded using loader options making use of wildcard tokens, such as _orm.load_only() or _orm.raiseload() with "*", would fail with KeyError or IndexError if the process doing the unpickling had not yet constructed a loader path making use of that same token. This would typically be observed when the object were unpickled in a separate process, such as with the spawn or forkserver multiprocessing start methods, the latter of which became the default on POSIX platforms as of Python 3.14. The internal collection of these tokens is now established up front, so that it is identical in every process.

    ... (truncated)

    Commits

    Updates `starlette` from 1.4.1 to 1.6.0
    Release notes

    Sourced from starlette's releases.

    Version 1.6.0

    What's Changed

    New Contributors

    Full Changelog: https://github.com/Kludex/starlette/compare/1.5.1...1.6.0

    Version 1.5.1

    What's Changed

    Full Changelog: https://github.com/encode/starlette/compare/1.5.0...1.5.1

    Version 1.5.0

    This release is all about giving GZipMiddleware some love. 🗜️

    What's Changed

    Full Changelog: https://github.com/encode/starlette/compare/1.4.1...1.5.0

    Changelog

    Sourced from starlette's changelog.

    1.6.0 (August 8, 2026)

    Added

    • Add max_body_size to Starlette and route classes #3431.
    • Expose http.response.debug information via response extensions #3130.

    1.5.1 (August 8, 2026)

    Fixed

    • Reject inverted single-byte ranges in FileResponse #3389.
    • Limit FileResponse to 100 ranges #3430.

    1.5.0 (August 8, 2026)

    Added

    • Add exclude_content_types parameter to GZipMiddleware #3418.

    Changed

    • Expand default excluded content types in GZipMiddleware #3421.

    Fixed

    • Flush GZip output for each streamed chunk #3419.
    • Skip compression of partial responses in GZipMiddleware #3420.
    Commits
    • 4f250d6 Version 1.6.0 (#3434)
    • 9eea41a Expose http.response.debug info via response extensions (#3130)
    • 38f8999 Add max_body_size to Starlette and route classes (#3431)
    • c41236c Version 1.5.1 (#3432)
    • 9c500db Limit FileResponse to 100 ranges (#3430)
    • 78ae82c Reject inverted single-byte Range like bytes=5-4 (#3389)
    • c1d6eda chore(deps): bump pymdown-extensions from 11.0 to 11.0.1 (#3429)
    • ee66ca4 chore(deps): bump the python-packages group across 1 directory with 8 updates...
    • 00d1016 fix(tests): skip test_staticfiles_filename_too_long on Windows where os.pathc...
    • d96887e Add Pydantic Logfire banner to the docs (#3428)
    • Additional commits viewable in compare view

    Updates `typing-inspection` from 0.4.2 to 0.4.4
    Release notes

    Sourced from typing-inspection's releases.

    v0.4.3 2026-08-10

    What's Changed

    Full Changelog: https://github.com/pydantic/typing-inspection/compare/v0.4.2...v0.4.3

    Changelog

    Sourced from typing-inspection's changelog.

    v0.4.4 (2026-08-12)

    v0.4.3 (2026-08-10)

    Commits

    Updates `uvicorn` from 0.52.1 to 0.52.3
    Release notes

    Sourced from uvicorn's releases.

    Version 0.52.3

    Changed

    • Update zttp to 0.0.24 and use its combined receive path, improving HTTP/1.1 request parsing performance (#3067)

    Full Changelog: https://github.com/Kludex/uvicorn/compare/0.52.2...0.52.3

    Version 0.52.2

    Fixed

    • Update zttp to 0.0.22, fixing bodyless request receives and improving HTTP/1 request parsing performance (#3063)

    Full Changelog: https://github.com/Kludex/uvicorn/compare/0.52.1...0.52.2

    Changelog

    Sourced from uvicorn's changelog.

    0.52.3 (August 13, 2026)

    Changed

    • Update zttp to 0.0.24 and use its combined receive path, improving HTTP/1.1 request parsing performance (#3067)

    0.52.2 (August 13, 2026)

    Fixed

    • Update zttp to 0.0.22, fixing bodyless request receives and improving HTTP/1 request parsing performance (#3063)
    Commits

    Updates `pre-commit` from 4.6.1 to 4.6.2
    Release notes

    Sourced from pre-commit's releases.

    pre-commit v4.6.2

    Fixes

    Changelog

    Sourced from pre-commit's changelog.

    4.6.2 - 2026-08-10

    Fixes

    Commits

    Updates `filelock` from 3.32.2 to 3.32.3
    Release notes

    Sourced from filelock's releases.

    3.32.3

    What's Changed

    Full Changelog: https://github.com/tox-dev/filelock/compare/3.32.2...3.32.3

    Changelog

    Sourced from filelock's changelog.

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

    .. towncrier-draft-entries:: Unreleased

    .. towncrier release notes start


    3.32.3 (2026-08-13)


    • The fork-safety audit hook no longer prints Exception ignored in audit hook with a TypeError when an audit event fires during interpreter shutdown, after CPython has already cleared the module globals. :pr:701

    3.32.2 (2026-07-29)


    • A SoftReadWriteLock or SoftFileLease acquire whose heartbeat thread fails to start now unlinks its marker and hands the claim back, instead of leaving an unrefreshed marker a peer takes while the caller believes it still holds the lock. :pr:691

    3.32.1 (2026-07-26)


    • Canceling an AsyncSoftReadWriteLock acquire now releases the claim instead of leaking a marker whose heartbeat wedges every contender. :pr:686

    3.32.0 (2026-07-21)


    • SoftReadWriteLock closes the directory handle it opens to scan for readers as soon as a scan stops early, rather than holding it until the generator is collected. :pr:685
    • Declare support for Python 3.15 and run the test suite against it and its free-threaded build, both currently in beta. :pr:683
    • The source distribution ships the capability probes the tests import, and reading one no longer needs coverage installed, so the suite runs from an unpacked sdist instead of failing on a missing coverage_pragmas. :pr:685

    3.31.2 (2026-07-21)


    • filelock imports again on runtimes whose errno omits ENOTSUP, such as GraalPy, where importing the package raised ImportError. It probes the code instead, preferring ENOTSUP, falling back to EOPNOTSUPP where that name is absent, and dropping to ENOSYS/EXDEV where neither exists. Platforms defining ENOTSUP keep their behavior. :pr:681

    ... (truncated)

    Commits
    • 4aa742c Release 3.32.3
    • fb5ab3e 🐛 fix(fork): survive audit events during interpreter shutdown (#703)
    • 35f759c 📄 docs: publish llms.txt from the docs build (#700)
    • 4b6e966 [pre-commit.ci] pre-commit autoupdate (#699)
    • 0e0f666 build(deps): bump pypa/gh-action-pypi-publish from 1.14.1 to 1.14.2 (#698)
    • 6689d82 🧪 test(strict): deflake close-fault injections on graalpy (#697)
    • df67bf7 [pre-commit.ci] pre-commit autoupdate (#696)
    • See full diff in compare view

    Updates `rasterio` from 1.5.0 to 1.5.1
    Release notes

    Sourced from rasterio's releases.

    1.5.1

    Version 1.5.1 fixes a number of bugs and unblocks the release of affine 3.0.0.

    Thanks for being patient, folks! It's a weird time in the open source software world.

    Bug fixes

    • DatasetBase.__dealloc__ has been removed and replaced by a __del__ method modeled after that of io.IOBase. This eliminates cryptic partial error messages from being printed as rio-calc finishes and finalizes (gh-3612).
    • The "bbox" attribute of GeoJSON data is no longer used by features.bounds() when optional parameters (transform and/or north_up=False) are provided. An unreported bug where transformation matrices were not applied to feature and geometry collections has also been fixed (gh-3609).
    • The size guard in sieve() was defective for 3D sources and has been fixed (gh-3606). As a side benefit, the dimensionality of output has been made consistent with the read() method of datasets: 3D output is no longer automatically squeezed to 2D. The previous behavior was a bug.
    • A test of subdataset support is skipped for GDAL versions >= 3.13.0 (gh-3534). The most recent versions of GDAL have a netCDF filename parsing defect.
    • Acknowledge new runtime warnings about invalid type casting using numpy (gh-3599).
    • Acknowledge the Python deprecation warnings from using "fork" start mode in testing warp error propagation (gh-3600).
    • Eliminate numpy deprecation warnings by reshaping views instead of setting their shape attributes (gh-3592).
    • Require pyparsing version >= 3.0 to eliminate a deprecation warning for use of parseString (gh-3551).
    • Remove a dependency on unmainted cligj by bringing some CLI options back into Rasterio (gh-3550).
    • Avoid a ValueError in reproject() when using a "==" comparison between array masks and np.ma.nomasked (gh-3531).

    Documentation

    • Note that PROJ transformation grids and the PROJ_NETWORK environment variable are required to apply vertical (datum) shifts when reprojecting or transforming (gh-2929).

    Dependencies

    • Rasterio 1.5.1 is compatible with affine versions 3.0rc1 and newer.

    Packaging:

    • Wheels include GDAL 3.12.4, netCDF 4.10.1, cURL 8.20.0, and PROJ 9.8.1. Python 3.15 is supported for the first time.
    Changelog

    Sourced from rasterio's changelog.

    1.5.1 (2026-08-07)

    Bug fixes:

    • DatasetBase.dealloc has been removed and replaced by a del method modeled after that of io.IOBase. This eliminates cryptic partial error messages from being printed as rio-calc finishes and finalizes (gh-3612).
    • The "bbox" attribute of GeoJSON data is no longer used by features.bounds() when optional parameters (transform and/or north_up=False) are provided. An unreported bug where transformation matrices were not applied to feature and geometry collections has also been fixed (gh-3609).
    • The size guard in sieve() was defective for 3D sources and has been fixed (gh-3606). As a side benefit, the dimensionality of output has been made consistent with the read() method of datasets: 3D output is no longer automatically squeezed to 2D. The previous behavior was a bug.
    • A test of subdataset support is skipped for GDAL versions >= 3.13.0 (gh-3534). The most recent versions of GDAL have a NetCDF filename parsing defect.
    • Acknowledge new runtime warnings about invalid type casting using numpy (gh-3599).
    • Acknowledge the Python deprecation warnings from using "fork" start mode in testing warp error propagation (gh-3600).
    • Eliminate numpy deprecation warnings by reshaping views instead of setting their shape attributes (gh-3592).
    • Require pyparsing version >= 3.0 to eliminate a deprecation warning for use of parseString (gh-3551).
    • Remove a dependency on unmaintained cligj by bringing some CLI options back into Rasterio (gh-3550).
    • Avoid a ValueError in reproject() when using a "==" comparison between array masks and np.ma.nomasked (gh-3531).

    Documentation:

    • Note that PROJ transformation grids and the PROJ_NETWORK environment variable are required to apply vertical (datum) shifts when reprojecting or transforming (gh-2929).

    Dependencies:

    • Rasterio 1.5.1 is compatible with affine versions 3.0rc1 and newer.

    Packaging:

    • Wheels include GDAL 3.12.4, netCDF 4.10.1, cURL 8.20.0, and PROJ 9.8.1.
    Commits

    Updates `sentry-sdk` from 2.66.1 to 2.68.0
    Release notes

    Sourced from sentry-sdk's releases.

    2.68.0

    Important

    • We're making enable_logs and enable_metrics no-op with this release (#7177), and they'll be dropped in the next major.

      Previously, enable_logs also controlled automatic logs collection from the logging and Loguru integrations. These integrations now get an integration-level capture_sentry_logs boolean option to allow for more control over the auto-collection. These options are False by default, i.e., nothing is auto-collected without your explicit opt-in.

      Action Needed

      If you had enable_logs set to True:

      • If you were using the sentry_sdk.logger.X API, no action necessary, the API will just work.
      • If you were auto-collecting logs from either LoggingIntegration or LoguruIntegration, the auto-collection will be turned off in this release. You can switch auto-collection on explicitly with:
      import sentry_sdk
      from sentry_sdk.integrations.logging import LoggingIntegration
      from sentry_sdk.integrations.loguru import LoguruIntegration
      

      sentry_sdk.init(
      integrations=[
      LoggingIntegration(capture_sentry_logs=True),
      LoguruIntegration(capture_sentry_logs=True),
      ],
      )

      If you had enable_logs set to False:

      • If you were using it to gate usages of the sentry_sdk.logger.X API, you'll need to remove the calls entirely or define a before_send_log callback to filter out unwanted logs.

      If you has enable_metrics set to False:

      • Any metrics emitted using the metrics API will be emitted. You'll need to drop them in a before_send_metric or remove the calls to the A... _Description has been truncated_ Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 24 +- requirements.txt | 726 ++++++++++++++++++++++++++++------------------- uv.lock | 471 +++++++++++++++++------------- 3 files changed, 722 insertions(+), 499 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ff715e38a..07a73d554 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "aiohttp==3.14.3", "aiosignal==1.4.0", "aiosqlite==0.22.1", - "alembic==1.19.0", + "alembic==1.19.1", "annotated-types==0.8.0", "anyio==4.14.2", "apitally[fastapi]==0.25.1", @@ -23,7 +23,7 @@ dependencies = [ "cachetools==7.1.7", "certifi==2026.7.22", "cffi==2.1.1", - "charset-normalizer==3.4.9", + "charset-normalizer==3.5.0", "click==8.4.2", "cloud-sql-python-connector==1.21.0", "cryptography==50.0.0", @@ -41,7 +41,7 @@ dependencies = [ "google-crc32c==1.8.0", "google-resumable-media==2.10.1", "googleapis-common-protos==1.75.1", - "greenlet==3.5.4", + "greenlet==3.5.5", "gunicorn==23.0.0", "h11==0.16.0", "httpcore==1.0.9", @@ -52,12 +52,12 @@ dependencies = [ "mako==1.4.1", "markupsafe==3.0.3", "multidict==6.7.1", - "numpy==2.5.1", + "numpy==2.5.2", "packaging==26.3", "pandas==2.3.2", "pandas-stubs~=2.3.2", "pg8000==1.31.5", - "phonenumbers==9.0.36", + "phonenumbers==9.0.37", "pillow==12.3.0", "pluggy==1.6.0", "propcache==0.5.2", @@ -80,24 +80,24 @@ dependencies = [ "pytz==2026.3.post1", "requests==2.34.2", "rsa==4.9.1", - "scramp==1.4.16", - "sentry-sdk[fastapi]==2.66.1", + "scramp==1.4.17", + "sentry-sdk[fastapi]==2.68.0", "shapely==2.1.2", "six==1.17.0", "sniffio==1.3.1", - "sqlalchemy==2.0.51", + "sqlalchemy==2.0.52", "sqlalchemy-continuum==1.7.0", "sqlalchemy-searchable==2.1.0", "sqlalchemy-utils==0.42.1", "sqlparse>=0.6.0", - "starlette==1.4.1", + "starlette==1.6.0", "typer==0.27.1", "typing-extensions==4.16.0", - "typing-inspection==0.4.2", + "typing-inspection==0.4.4", "tzdata==2026.3", "urllib3==2.7.0", "utm==0.9.0", - "uvicorn==0.52.1", + "uvicorn==0.52.3", "yarl==1.24.5", "pymssql>=2.3.13", ] @@ -134,7 +134,7 @@ dev = [ "black>=26.5.1", "faker>=25.0.0", "flake8>=7.3.0", - "pre-commit>=4.6.1", + "pre-commit>=4.6.2", "pyhamcrest>=2.0.3", "pytest>=9.1.1", "pytest-cov>=6.2.1", diff --git a/requirements.txt b/requirements.txt index a5c05c2d7..f628a6a45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -149,9 +149,9 @@ aiosqlite==0.22.1 \ --hash=sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650 \ --hash=sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb # via ocotilloapi -alembic==1.19.0 \ - --hash=sha256:6487c612fc719dcfa22b17d2dd5b2b458929641e6aa2f0b65b135727f5e6d501 \ - --hash=sha256:cf839d3849116aab3cc047e09c6968b9bd6b2fde61b6bb7c1e97352fe5503580 +alembic==1.19.1 \ + --hash=sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be \ + --hash=sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648 # via ocotilloapi annotated-doc==0.0.5 \ --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ @@ -404,100 +404,179 @@ cffi==2.1.1 \ # via # cryptography # ocotilloapi -charset-normalizer==3.4.9 \ - --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ - --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ - --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \ - --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \ - --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \ - --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \ - --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \ - --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \ - --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \ - --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \ - --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \ - --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \ - --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \ - --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \ - --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \ - --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \ - --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \ - --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \ - --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \ - --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \ - --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \ - --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \ - --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \ - --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \ - --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \ - --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \ - --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \ - --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \ - --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \ - --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \ - --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \ - --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \ - --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \ - --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \ - --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \ - --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \ - --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \ - --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \ - --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \ - --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \ - --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \ - --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \ - --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \ - --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \ - --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \ - --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \ - --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \ - --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \ - --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \ - --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \ - --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \ - --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \ - --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \ - --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \ - --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \ - --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \ - --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \ - --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \ - --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \ - --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \ - --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \ - --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \ - --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \ - --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \ - --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \ - --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \ - --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \ - --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \ - --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \ - --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \ - --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \ - --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \ - --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \ - --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \ - --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \ - --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \ - --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \ - --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \ - --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \ - --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \ - --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \ - --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \ - --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \ - --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \ - --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \ - --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \ - --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \ - --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \ - --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \ - --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \ - --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \ - --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ - --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 +charset-normalizer==3.5.0 \ + --hash=sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08 \ + --hash=sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6 \ + --hash=sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6 \ + --hash=sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f \ + --hash=sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9 \ + --hash=sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b \ + --hash=sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74 \ + --hash=sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3 \ + --hash=sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b \ + --hash=sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f \ + --hash=sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046 \ + --hash=sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a \ + --hash=sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4 \ + --hash=sha256:143792a43e06dc3b27fc891948406e251502dc19ff9216cd80182b79131be5c5 \ + --hash=sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af \ + --hash=sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19 \ + --hash=sha256:17a0fd0e23961c2c017372e37aabc7ca8fceb9e10ad898977dfb40ad3927baae \ + --hash=sha256:17db18db9a1374d5b9d9a3252f980b4243b0b4efd1df03fac78bb587f6ce98cd \ + --hash=sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e \ + --hash=sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458 \ + --hash=sha256:1a573e1e428f93908e79e04b349717f400e720f2f82285f0aaaf3ee0ff7f4c79 \ + --hash=sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075 \ + --hash=sha256:1cdfed4d7a59333c8220c67dd3be4e7a6c887b67453a64394022dcc919570add \ + --hash=sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a \ + --hash=sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0 \ + --hash=sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6 \ + --hash=sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e \ + --hash=sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26 \ + --hash=sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1 \ + --hash=sha256:2403b489c103e9a18c835863fc6dd54361355c8291d4cafdb37492b683440b9b \ + --hash=sha256:2df26d4134948616be0ece05d0b24d621d3990f37147b5883c52052b613ef1f5 \ + --hash=sha256:301bfc4877c4f4f62b344235ecc58d06c901683801636eef819f88769c315ba2 \ + --hash=sha256:30ae26a1adcd943690dcbbc47f28be762bae9e08ad7442b78c86b1c0dd5a626c \ + --hash=sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3 \ + --hash=sha256:32e6d56dd825205f81e5c45bcebb4df6a11fb2bbf4969a01ef156d6ced90c224 \ + --hash=sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700 \ + --hash=sha256:3587d94b5c9f05c2dc4c3f3d47aba6375ff141a21adae3051d8d4d53e8a937c0 \ + --hash=sha256:3684ebbdffd51329ac44245d1d227d90b965797aa1a8abd026568a1f6ae88811 \ + --hash=sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991 \ + --hash=sha256:38a395079f229a631dece74e24c69c1f612536dd51f345a7d6a98abe2d3e047a \ + --hash=sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be \ + --hash=sha256:3bfbe543d957213fc9a3db4979a8e171b7aa7504c1d737029defdb03a6095a38 \ + --hash=sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee \ + --hash=sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44 \ + --hash=sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb \ + --hash=sha256:420b19411959eec115063229536788e6b32d0a7fa907d6b940317919120d702d \ + --hash=sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394 \ + --hash=sha256:4346a693c08b1d0cfc0e3325bfb0ecd4322fb1a6904d68cf416f8da5e981b234 \ + --hash=sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0 \ + --hash=sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0 \ + --hash=sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e \ + --hash=sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9 \ + --hash=sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3 \ + --hash=sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03 \ + --hash=sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2 \ + --hash=sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca \ + --hash=sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715 \ + --hash=sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f \ + --hash=sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5 \ + --hash=sha256:5a54587f93f2e289f8faf25b35c997d4cc75cf677485ac6f50c985715989f99c \ + --hash=sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293 \ + --hash=sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144 \ + --hash=sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74 \ + --hash=sha256:5f51a19dc52197a20218b05ec5336d0c6b3b09935f838724722032c8d45dc91a \ + --hash=sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e \ + --hash=sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d \ + --hash=sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956 \ + --hash=sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d \ + --hash=sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f \ + --hash=sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6 \ + --hash=sha256:69d647cf158eb6bc9c99503292abed1f2079a2de5859f06a403f8aee6417475d \ + --hash=sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438 \ + --hash=sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e \ + --hash=sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1 \ + --hash=sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9 \ + --hash=sha256:6da562a20a49673fe365b05750e98d03bb2c5f8b8d03562b014c1abb3df739f1 \ + --hash=sha256:6e44bc2780516b3df986d6fe33103c7080cd9dcd5576fe3cb4b0f64309c8f22b \ + --hash=sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b \ + --hash=sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a \ + --hash=sha256:74892fe9f33d204860e782e0a2030bb39f9f0af1e7a24f7d5a5b632df311f655 \ + --hash=sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4 \ + --hash=sha256:7cdded069549b5eae3d5d9bb6c2e5bb4fe83f9b81863e2a193cd747bf197aebb \ + --hash=sha256:7faa47b56070b3dd6f4898ed28528843ab130d53266cb9948d9b1f3bb1a5c5e8 \ + --hash=sha256:7ffc43fe52618fcd7abc6ee0b46aea527db10da73305fcc6aaf9710ac7a33ec7 \ + --hash=sha256:815f143a91983ba3041bba066e492ae3c42de523fb1c699685a1abf3313b7d1b \ + --hash=sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64 \ + --hash=sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c \ + --hash=sha256:830c04a49998b5ed58c8b642c65b7b26419397f52392a64121ba9fd0e95e7f9f \ + --hash=sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731 \ + --hash=sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3 \ + --hash=sha256:85f9e0e2724bbddf05de65e5fb03b73eb23e985b7df4259c1d19feb302eb8dc2 \ + --hash=sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516 \ + --hash=sha256:8b8788f114845c01f2b520e0b91ea58d143276cfc0483aa943e815f7b9555c15 \ + --hash=sha256:8cb9b6892b53bd6d11fa4cde3dbee020b1f0b6656be1fbaa1ec0d4324a7839db \ + --hash=sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc \ + --hash=sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d \ + --hash=sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0 \ + --hash=sha256:9491f594859b68052edebd69e05fb045055a713b57a67974e6c1553b4e503c39 \ + --hash=sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78 \ + --hash=sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603 \ + --hash=sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d \ + --hash=sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea \ + --hash=sha256:9a1d9b13e5e394e13e3c316f0d910d100b17681ff59797f30da1dba032061296 \ + --hash=sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85 \ + --hash=sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76 \ + --hash=sha256:9e0213f3f8a2674a6778be299aea1d6dc6dda015aab86f683bca6d78f81f27bb \ + --hash=sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7 \ + --hash=sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97 \ + --hash=sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897 \ + --hash=sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec \ + --hash=sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d \ + --hash=sha256:a565303d118ea3b94a4b6c076bf568069726be414e43b06d58f7070b076ce11d \ + --hash=sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4 \ + --hash=sha256:a7cb4cd266bd85613367fb85a30cfbf6fe6349919e87e18ca8dba584951bfb8a \ + --hash=sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3 \ + --hash=sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8 \ + --hash=sha256:ac68ebfa549cc623e0e9add2937526340c629ccf667b4da85b7ef5f99e70bbd9 \ + --hash=sha256:aff38231e3171c578b2c449a01afa44e9ff40844597a32873da102394f63d28e \ + --hash=sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288 \ + --hash=sha256:b787efadba00f5da6fe89513bfbe3852d52ca3a448fdec165765cb3b44a80248 \ + --hash=sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3 \ + --hash=sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6 \ + --hash=sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73 \ + --hash=sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b \ + --hash=sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a \ + --hash=sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce \ + --hash=sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f \ + --hash=sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b \ + --hash=sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706 \ + --hash=sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9 \ + --hash=sha256:c5e981a5ac8641381efe6f0029467500661616a530d27bc6eedfe45f840599f8 \ + --hash=sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4 \ + --hash=sha256:c825661dfcf843119ab57cdcac0df7a48e168764c66917bc74f9a42ecb096da9 \ + --hash=sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a \ + --hash=sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757 \ + --hash=sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2 \ + --hash=sha256:d08952c0f14eb56d9dad72a2e17773b5f709c55b28635822d18c4adf38680833 \ + --hash=sha256:d22a083497d2f7d06a57172c5b60ee66cedcf304fde5226d4dfdc94f6180f5b1 \ + --hash=sha256:d2478bd3b2ead3962a484fb802891be40d10049fb74f83e09cb4463fad023fea \ + --hash=sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45 \ + --hash=sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa \ + --hash=sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539 \ + --hash=sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775 \ + --hash=sha256:d74bcf1cdd8ac8267fb216473ce6b112efa07b163536288094541415084d131c \ + --hash=sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636 \ + --hash=sha256:d867cefea33acad8e33a3eb408cca7889a9cf999bd5433d962089d5a13b6e75f \ + --hash=sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45 \ + --hash=sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8 \ + --hash=sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258 \ + --hash=sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2 \ + --hash=sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834 \ + --hash=sha256:dc7f6aca0bdac5e6520c8b6769bda69315fe7cb57f69885f115bc8ca02d1d022 \ + --hash=sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142 \ + --hash=sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80 \ + --hash=sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040 \ + --hash=sha256:e46a37ea7fcf9ae01d71b2e5ece19f1565987f3e308394b829197cbefc061f92 \ + --hash=sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd \ + --hash=sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14 \ + --hash=sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40 \ + --hash=sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4 \ + --hash=sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0 \ + --hash=sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396 \ + --hash=sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa \ + --hash=sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50 \ + --hash=sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d \ + --hash=sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053 \ + --hash=sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698 \ + --hash=sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b \ + --hash=sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49 \ + --hash=sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888 \ + --hash=sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e \ + --hash=sha256:ffdd7ac514301d0a67f7c23b9f2b431ef909a3c3dd6c3766668d0a6f5900c94e # via # ocotilloapi # requests @@ -613,9 +692,9 @@ fastapi-pagination==0.15.16 \ --hash=sha256:739a4e904729dc01e03ce95e260ed9be050d48a3659be8463e5c4fcdd0cf25b0 \ --hash=sha256:86dc73620812d47c297a7b8baca4eba2bac5d3f1d73aa75dc6e3bb12c0b803f7 # via ocotilloapi -filelock==3.32.2 \ - --hash=sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82 \ - --hash=sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8 +filelock==3.32.3 \ + --hash=sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f \ + --hash=sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09 # via pygeoapi flask==3.1.3 \ --hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \ @@ -750,86 +829,86 @@ googleapis-common-protos==1.75.1 \ # via # google-api-core # ocotilloapi -greenlet==3.5.4 \ - --hash=sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20 \ - --hash=sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c \ - --hash=sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994 \ - --hash=sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8 \ - --hash=sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d \ - --hash=sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9 \ - --hash=sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f \ - --hash=sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809 \ - --hash=sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c \ - --hash=sha256:188e4d142f243051d92a1f5c244a741da02dddc070a0620c842804d7b56d008c \ - --hash=sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72 \ - --hash=sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3 \ - --hash=sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02 \ - --hash=sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c \ - --hash=sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c \ - --hash=sha256:2cdaadc3d31445a8f782bde3cd37e49a2c2a9c6da6daf76a3e34c683b271a3c7 \ - --hash=sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec \ - --hash=sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c \ - --hash=sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686 \ - --hash=sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861 \ - --hash=sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8 \ - --hash=sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0 \ - --hash=sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4 \ - --hash=sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9 \ - --hash=sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3 \ - --hash=sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9 \ - --hash=sha256:4ab9f0704bccf6d3b38e0d2130b7b33271cff11453690da074fa280c3aa8e8e7 \ - --hash=sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7 \ - --hash=sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd \ - --hash=sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3 \ - --hash=sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2 \ - --hash=sha256:69173331fbc5d64bfac0065d7e22c39cfcd089e9b18d125bdcd5079363b09616 \ - --hash=sha256:70bdfacdc183dac838b2a0aaff2dd6134a457c52fe68a9c6bbab435483d2b9df \ - --hash=sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf \ - --hash=sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0 \ - --hash=sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a \ - --hash=sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f \ - --hash=sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22 \ - --hash=sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356 \ - --hash=sha256:89f3738167bab8c1084b94e23023d41d247117ac149fa0fbcb5bd4cf6262b353 \ - --hash=sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e \ - --hash=sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7 \ - --hash=sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5 \ - --hash=sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8 \ - --hash=sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde \ - --hash=sha256:a2d614cb2372c7101a12ea8b96dd56f81c986d247c5a73db67063f3ed1ca4a52 \ - --hash=sha256:ac5bf81d79d2c8eeb2ef6359b2e1687a1e9ebf46c2b1f970da9a9255df51d190 \ - --hash=sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05 \ - --hash=sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937 \ - --hash=sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867 \ - --hash=sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d \ - --hash=sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf \ - --hash=sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f \ - --hash=sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd \ - --hash=sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da \ - --hash=sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071 \ - --hash=sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88 \ - --hash=sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17 \ - --hash=sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c \ - --hash=sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66 \ - --hash=sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb \ - --hash=sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c \ - --hash=sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25 \ - --hash=sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0 \ - --hash=sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927 \ - --hash=sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6 \ - --hash=sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c \ - --hash=sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59 \ - --hash=sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb \ - --hash=sha256:e9a5e3406e3ed8125ae1a3b37c12f3434e2b1f0fa053197c5557895b4fb09606 \ - --hash=sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef \ - --hash=sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3 \ - --hash=sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da \ - --hash=sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132 \ - --hash=sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7 \ - --hash=sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f \ - --hash=sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2 \ - --hash=sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f \ - --hash=sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667 +greenlet==3.5.5 \ + --hash=sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537 \ + --hash=sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39 \ + --hash=sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277 \ + --hash=sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41 \ + --hash=sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2 \ + --hash=sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d \ + --hash=sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53 \ + --hash=sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e \ + --hash=sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502 \ + --hash=sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5 \ + --hash=sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc \ + --hash=sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759 \ + --hash=sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f \ + --hash=sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b \ + --hash=sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1 \ + --hash=sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5 \ + --hash=sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769 \ + --hash=sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0 \ + --hash=sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f \ + --hash=sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da \ + --hash=sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76 \ + --hash=sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3 \ + --hash=sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e \ + --hash=sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476 \ + --hash=sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e \ + --hash=sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380 \ + --hash=sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef \ + --hash=sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18 \ + --hash=sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b \ + --hash=sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272 \ + --hash=sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0 \ + --hash=sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053 \ + --hash=sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07 \ + --hash=sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387 \ + --hash=sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52 \ + --hash=sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed \ + --hash=sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95 \ + --hash=sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c \ + --hash=sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad \ + --hash=sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f \ + --hash=sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db \ + --hash=sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328 \ + --hash=sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8 \ + --hash=sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71 \ + --hash=sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc \ + --hash=sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864 \ + --hash=sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0 \ + --hash=sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1 \ + --hash=sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b \ + --hash=sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667 \ + --hash=sha256:86c5113d698cb8d927b2750bb1f1d59eefe3a37e0e0217491aee29a7f84ef52c \ + --hash=sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c \ + --hash=sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926 \ + --hash=sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc \ + --hash=sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd \ + --hash=sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007 \ + --hash=sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6 \ + --hash=sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56 \ + --hash=sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0 \ + --hash=sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b \ + --hash=sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53 \ + --hash=sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c \ + --hash=sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c \ + --hash=sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474 \ + --hash=sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa \ + --hash=sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61 \ + --hash=sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206 \ + --hash=sha256:c69bed34470abfcd456984fdadaa18e62169af4480335c45f3c32d1d9c12e638 \ + --hash=sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9 \ + --hash=sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874 \ + --hash=sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d \ + --hash=sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8 \ + --hash=sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae \ + --hash=sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0 \ + --hash=sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773 \ + --hash=sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4 \ + --hash=sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552 \ + --hash=sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42 \ + --hash=sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b # via # ocotilloapi # sqlalchemy @@ -1049,51 +1128,73 @@ multidict==6.7.1 \ # aiohttp # ocotilloapi # yarl -numpy==2.5.1 \ - --hash=sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2 \ - --hash=sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d \ - --hash=sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1 \ - --hash=sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b \ - --hash=sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd \ - --hash=sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077 \ - --hash=sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a \ - --hash=sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e \ - --hash=sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277 \ - --hash=sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6 \ - --hash=sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75 \ - --hash=sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7 \ - --hash=sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1 \ - --hash=sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9 \ - --hash=sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21 \ - --hash=sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca \ - --hash=sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0 \ - --hash=sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb \ - --hash=sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d \ - --hash=sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75 \ - --hash=sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74 \ - --hash=sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf \ - --hash=sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0 \ - --hash=sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8 \ - --hash=sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af \ - --hash=sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a \ - --hash=sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4 \ - --hash=sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22 \ - --hash=sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3 \ - --hash=sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1 \ - --hash=sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b \ - --hash=sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1 \ - --hash=sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373 \ - --hash=sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95 \ - --hash=sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6 \ - --hash=sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09 \ - --hash=sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9 \ - --hash=sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438 \ - --hash=sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2 \ - --hash=sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7 \ - --hash=sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace \ - --hash=sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3 \ - --hash=sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2 \ - --hash=sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107 +numpy==2.5.2 \ + --hash=sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a \ + --hash=sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f \ + --hash=sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7 \ + --hash=sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0 \ + --hash=sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3 \ + --hash=sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c \ + --hash=sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce \ + --hash=sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8 \ + --hash=sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1 \ + --hash=sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4 \ + --hash=sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee \ + --hash=sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740 \ + --hash=sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98 \ + --hash=sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710 \ + --hash=sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee \ + --hash=sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68 \ + --hash=sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf \ + --hash=sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8 \ + --hash=sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf \ + --hash=sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b \ + --hash=sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884 \ + --hash=sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03 \ + --hash=sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69 \ + --hash=sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4 \ + --hash=sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842 \ + --hash=sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65 \ + --hash=sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080 \ + --hash=sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e \ + --hash=sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e \ + --hash=sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414 \ + --hash=sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59 \ + --hash=sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8 \ + --hash=sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617 \ + --hash=sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4 \ + --hash=sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb \ + --hash=sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251 \ + --hash=sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d \ + --hash=sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2 \ + --hash=sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab \ + --hash=sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657 \ + --hash=sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15 \ + --hash=sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9 \ + --hash=sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8 \ + --hash=sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323 \ + --hash=sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788 \ + --hash=sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc \ + --hash=sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56 \ + --hash=sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1 \ + --hash=sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d \ + --hash=sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec \ + --hash=sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2 \ + --hash=sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e \ + --hash=sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7 \ + --hash=sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26 \ + --hash=sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514 \ + --hash=sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860 \ + --hash=sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a \ + --hash=sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1 \ + --hash=sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab \ + --hash=sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba \ + --hash=sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12 \ + --hash=sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6 \ + --hash=sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e \ + --hash=sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac \ + --hash=sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb \ + --hash=sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f # via # ocotilloapi # pandas @@ -1145,9 +1246,9 @@ pg8000==1.31.5 \ --hash=sha256:0af2c1926b153307639868d2ee5cef6cd3a7d07448e12736989b10e1d491e201 \ --hash=sha256:46ebb03be52b7a77c03c725c79da2ca281d6e8f59577ca66b17c9009618cae78 # via ocotilloapi -phonenumbers==9.0.36 \ - --hash=sha256:60ca2a6870d2532d6e960105790e85e9d69270ede81746d10cf57f5e437b93ec \ - --hash=sha256:c16a5b95178345ec2df1954578a4ac09e937443b2ee992dd7b15a8e8c81f4acf +phonenumbers==9.0.37 \ + --hash=sha256:55ae8243c1e2fe8ae2548d979e57155d9ae0f6084f8a1d6884050b143aff0a7c \ + --hash=sha256:8923ab413c575b27da2815c9bef48290181aab565398b5e7e23cc7ace212a293 # via ocotilloapi pillow==12.3.0 \ --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ @@ -1656,32 +1757,42 @@ pyyaml==6.0.3 \ --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 # via pygeoapi -rasterio==1.5.0 \ - --hash=sha256:015c1ab6e5453312c5e29692752e7ad73568fe4d13567cbd448d7893128cbd2d \ - --hash=sha256:08a7580cbb9b3bd320bdf827e10c9b2424d0df066d8eef6f2feb37e154ce0c17 \ - --hash=sha256:0c739e70a72fb080f039ee1570c5d02b974dde32ded1a3216e1f13fe38ac4844 \ - --hash=sha256:1162c18eaece9f6d2aa1c2ff6b373b99651d93f113f24120a991eaebf28aa4f4 \ - --hash=sha256:19577f0f0c5f1158af47b57f73356961cbd1782a5f6ae6f3adf6f2650f4eb369 \ - --hash=sha256:1e0ea56b02eea4989b36edf8e58a5a3ef40e1b7edcb04def2603accd5ab3ee7b \ - --hash=sha256:2f57c36ca4d3c896f7024226bd71eeb5cd10c8183c2a94508534d78cc05ff9e7 \ - --hash=sha256:508251b9c746d8d008771a30c2160ff321bfc3b41f6a1aa8e8ef1dd4a00d97ba \ - --hash=sha256:592a485e2057b1aaeab4f843c9897628e60e3ff45e2509325c3e1479116599cb \ - --hash=sha256:597be8df418d5ba7b6a927b6b9febfcb42b192882448a8d5b2e2e75a1296631f \ - --hash=sha256:62c3f97a3c72643c74f2d0f310621a09c35c0c412229c327ae6bcc1ee4b9c3bc \ - --hash=sha256:742841ed48bc70f6ef517b8fa3521f231780bf408fde0aa6d73770337a36374e \ - --hash=sha256:8af7c368c22f0a99d1259ccc5a5cd96c432c2bde6f132c1ac78508cd7445a745 \ - --hash=sha256:8eb87fd6f843eea109f3df9bef83f741b053b716b0465932276e2c0577dfb929 \ - --hash=sha256:a3539a2f401a7b4b2e94ff2db334878c0e15a2d1c9fe90bb0879c52f89367ae5 \ - --hash=sha256:b4ccfcc8ed9400e4f14efdf2005533fcf72048748b727f85ff89b9291ecdf98a \ - --hash=sha256:b9fd87a0b63ab5c6267dfb0bc96f54fdf49d000651b9ee85ed37798141cff046 \ - --hash=sha256:c9a9eee49ce9410c2f352b34c370bb3a96bb518b6a7f97b3a72ee4c835fd4b5c \ - --hash=sha256:cc1395475e4bb7032cd81dda4d5558061c4c7d5a50b1b5e146bdf9716d0b9353 \ - --hash=sha256:d7d6729c0739b5ec48c33686668a30e27f5bdb361093f180ee7818ff19665547 \ - --hash=sha256:dd292030d39d685c0b35eddef233e7f1cb8b43052578a3ec97a2da57799693be \ - --hash=sha256:e7b25b0a19975ccd511e507e6de45b0a2d8fb6802abe49bb726cf48588e34833 \ - --hash=sha256:f459db8953ba30ca04fcef2b5e1260eeeff0eae8158bd9c3d6adbe56289765cc \ - --hash=sha256:f4b9c2c3b5f10469eb9588f105086e68f0279e62cc9095c4edd245e3f9b88c8a \ - --hash=sha256:ff677c0a9d3ba667c067227ef2b76872488b37ff29b061bc3e576fad9baa3286 +rasterio==1.5.1 \ + --hash=sha256:00c78697cb565e97f99deb485fc7f23f28ff30e857124872ef0c5a3e83dd7e47 \ + --hash=sha256:01f927ee7ef08cbc111b34092df266308fd2c64fad2e21a97c991973eb8e0c25 \ + --hash=sha256:0898355908ed3614ba1415532432cf4a1a308b189b2c9d8307a26a44bcb1713b \ + --hash=sha256:11e2e9c68971beeec603c2613fa7740cf0a02dd026ef1aaa75d1cdf4246fbde6 \ + --hash=sha256:3aa1a3441587341b78558b5359aa5d2694b45a911d7b5df23af726420e420bce \ + --hash=sha256:546ca44c4417772a48c5224b037f10330a75cde74b429f07ac43838e20f9b2c9 \ + --hash=sha256:55e18b8a6a7f4a9769942d48a1545c5a97920cf841fe5e4099032ba855bdf95c \ + --hash=sha256:5933e56b15ef29fcbec370a2e0b5ac39b33023d85a3f5b01cdf0cbe4231c6204 \ + --hash=sha256:5ff9db698a88e716254fb0788768f75d7b58532c8253674261fa05abd40f621e \ + --hash=sha256:60d74d11f290510639046c447256085eae0a925acdfe07e37f7436fb97cb9f46 \ + --hash=sha256:6fbafe970d44ec06179c7884cdb160a90311e3d19da25bff970fab06123f0201 \ + --hash=sha256:821d49afd18498fb0918b72961b1b667193c64edf8cc484081c522d10b33980d \ + --hash=sha256:82719d4de76f3bafb165e932ad997b07116aa08621db079750ef1031dc514e11 \ + --hash=sha256:833511f045ca49dafbf25da4762a0b19dcf5d046ded8b318c094363e2d65f474 \ + --hash=sha256:8683074903018918341908e714f792ed5d696bad441ecec1787de8eb896573cd \ + --hash=sha256:9093a9277559ac0f954d9a0266886bc4037e3ffd7a40837f6ea6486a96c8c258 \ + --hash=sha256:937944c445d4ea4d6969e9dbfcf305143c05fc4df2f8fadfa360cb2d0f65d7b0 \ + --hash=sha256:94ae0fe4c0eb031da99614a1a1cd27819f900f86bc84976cd9f713eee05584a3 \ + --hash=sha256:a270366c4f44f7bb4367d7068b6de08c56b2b04049e3dfcd06d451141fee3a7f \ + --hash=sha256:a685d2e97684b275b12859f9e0b95a8f511a51a2ac51b284428c6f50f1bcfd17 \ + --hash=sha256:ac450a2c1e6de990eaaee347ebceca160b1531c9f13c5db489d0e3113ea13984 \ + --hash=sha256:b117209922784ef7948f702a0e9eb26be744f4b24d31f5eb97c45c0f04e2e4c3 \ + --hash=sha256:b3686865af2c67114f5dfb67df483db61bccad4078083a3dad481ea035e5d701 \ + --hash=sha256:bf5d2cf43791651522fedb284cc08398f96fecc018dff24d7fc97965c909ab3a \ + --hash=sha256:c0cd79ed52481d55395f1d3def075ff45173ed07ccd238fddeb0c63522b3d137 \ + --hash=sha256:c1b6ae15f4ccad704f1fe8417da5c2250145c7bcdb91acb53833bf5aefdd9e48 \ + --hash=sha256:c209e670abf0f30695a784c4f0170366462b9a3eaea3e49122ab2ce81799460b \ + --hash=sha256:c361ed93cb8ceb314bf42d98815f2b6dd0254f7367d2b932435a8cf0aeaecf96 \ + --hash=sha256:c8702772d6e91c0578d84af88de7d4926c48b0b6eca33d0660e90d7f37c06bf1 \ + --hash=sha256:d8064e9b2820c3f1027399f410d8cdbfb57bdedfad46e5381afb527a9b6f74a2 \ + --hash=sha256:e31b65231a631b0b59471aeae4423167d42f88da0c5ee5a519dc07deb7a4666b \ + --hash=sha256:e4ea970d39f0b134fc91141d767b3c49bc2dea38a32f0b036fbdd5ec2e8ca457 \ + --hash=sha256:e863b614cadad435a4394792b6cdc84324b15197629ad5a20c8d56cbc4eeff92 \ + --hash=sha256:ee01dc4b114a21078af87da916816c6c62ba5b5d216725f148ae695ef4deebf2 \ + --hash=sha256:fbb933659035f0aca32e4dc0dc022c7162b4f6598b15366c5b19b4a7e65c1991 # via pygeoapi referencing==0.37.0 \ --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ @@ -1887,15 +1998,15 @@ rsa==4.9.1 \ # via # ocotilloapi # python-jose -scramp==1.4.16 \ - --hash=sha256:836bcecdc8843af76b35e11adbe818e3d7934d0d25aa48fd2e8cde362a2c6a9b \ - --hash=sha256:9e6148fca008ab6c6ce84658aeefd03d5d7f269d32fe08d07e134dae089e937a +scramp==1.4.17 \ + --hash=sha256:28970f29ebc33df47f9975c805e5e5a360effe5b31045e607d64b3b60370dba1 \ + --hash=sha256:a4e3fd2e8169461a28a13777a166d3da94274454f0714a7d3023fee124474ac8 # via # ocotilloapi # pg8000 -sentry-sdk==2.66.1 \ - --hash=sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6 \ - --hash=sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc +sentry-sdk==2.68.0 \ + --hash=sha256:538e56c2d03679d42f7c0cb5f1af73a7a510b00abc7e296c13ac49b107b713a4 \ + --hash=sha256:648c58e9887311a03470a41539e24bdbbf64a30ca4f5336f7e3dcc87276400b3 # via ocotilloapi shapely==2.1.2 \ --hash=sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9 \ @@ -1949,30 +2060,59 @@ sniffio==1.3.1 \ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc # via ocotilloapi -sqlalchemy==2.0.51 \ - --hash=sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23 \ - --hash=sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8 \ - --hash=sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72 \ - --hash=sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5 \ - --hash=sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e \ - --hash=sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2 \ - --hash=sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f \ - --hash=sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d \ - --hash=sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54 \ - --hash=sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195 \ - --hash=sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522 \ - --hash=sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491 \ - --hash=sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400 \ - --hash=sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07 \ - --hash=sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7 \ - --hash=sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9 \ - --hash=sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7 \ - --hash=sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499 \ - --hash=sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d \ - --hash=sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b \ - --hash=sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5 \ - --hash=sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d \ - --hash=sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de +sqlalchemy==2.0.52 \ + --hash=sha256:11560064cc4696e772298b6221ede59e646386d9f2a85d549365473b972f7850 \ + --hash=sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee \ + --hash=sha256:1b92a1e23ed40022081217b40d2d1feba4f77064e69ef4f39f68bcbbd148452a \ + --hash=sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca \ + --hash=sha256:2e15b1d1116a64fc399b8c2694a83f3e792fdc58df28514a81e1dc4f8cf22729 \ + --hash=sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43 \ + --hash=sha256:2f5fa2b2aca75d2c7f36db3a8dd04717b6fbfd1a964fb32bdeae16698e475ab3 \ + --hash=sha256:2f9eccf8793c8c3f8dd2dfd11b9e400cb27d1d19370ef732b66017e212107822 \ + --hash=sha256:309cc8ba50fc5d2174189dfcd49cdf7aa711f8346afcff19f2642ae4fc449c14 \ + --hash=sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee \ + --hash=sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89 \ + --hash=sha256:3c95c3044edddb65e4a2f7194ec52ca5a9736f72d33ca3a6fa4196aedcc689fd \ + --hash=sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9 \ + --hash=sha256:4699dbb8d396d199e7e78fd4d525e3ad3d6008a9c8c0160b87e74c606c2c3736 \ + --hash=sha256:46f0c46f0d360d727b84660b26c62b295d82306ec2c82b701e97747d2c6dcbe1 \ + --hash=sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd \ + --hash=sha256:4b89e93bb89eabdbea9d5d3fa2d6cc6544e733c33064339f91e5292480cf130e \ + --hash=sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8 \ + --hash=sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97 \ + --hash=sha256:5f8438a98d49424acf69d0d53c0a522951dfe49a6f2d86417fbb37ad3066ab43 \ + --hash=sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c \ + --hash=sha256:6c1b7ed45bf87b214e0a9def9c2313949067efe6269db5ef18d542ee13250af7 \ + --hash=sha256:765f439da5bc8696973bc0c8a31fae0912ac3ff1cb9d66246a6b2728ee4fbbc8 \ + --hash=sha256:77a247d3fd179f6583171e7e0e98f40dc6642ed4f655557515a5a7e25923e9a4 \ + --hash=sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608 \ + --hash=sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437 \ + --hash=sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b \ + --hash=sha256:8cf993f065bc04caa5000b339e8d9d6f3d9d00251511f850147c516c9e07115f \ + --hash=sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1 \ + --hash=sha256:9255ceb65a80c1b001129060b63ee776a2e9c288be3b662be36dfbb888fffdcd \ + --hash=sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1 \ + --hash=sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751 \ + --hash=sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d \ + --hash=sha256:a7438774e1091192fc50a2bd8ceff5c596912d00ecd46587e88effdea7826101 \ + --hash=sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057 \ + --hash=sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2 \ + --hash=sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e \ + --hash=sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15 \ + --hash=sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727 \ + --hash=sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582 \ + --hash=sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d \ + --hash=sha256:cce4922535db73f9dbb91e3db2b3e851ac629467fd1ebd8e354a60e369521c63 \ + --hash=sha256:cd9206024b8602e7518bbaf44016c29e0045722f09328d8e654941023920d0b3 \ + --hash=sha256:cef328349452ae152637df4d11ce5a0919ecdf0a363e16c830c3518ee33bde72 \ + --hash=sha256:de89de5b5798cafdd7ef7b7b804acec246d6152922128fd9d156cd1701271aff \ + --hash=sha256:df8f213ceb485d8227b74935eb87ba0d80169a8401eba7835da6e30d6727dac4 \ + --hash=sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e \ + --hash=sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c \ + --hash=sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf \ + --hash=sha256:f1c850792a3b25a3ad74dade3f05e4f402cdebfea27438bcadafaa1617f77bcc \ + --hash=sha256:f2b09029ef6f260409eefa5dc2b8276f6c3d7b892bfb50d50e8f852257d4a6b4 \ + --hash=sha256:f4d4f7afc682961dc567db70e00a7b5bd81ccd3743c46199b0257f0744902dde # via # alembic # geoalchemy2 @@ -1999,9 +2139,9 @@ sqlparse==0.6.0 \ --hash=sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9 \ --hash=sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f # via ocotilloapi -starlette==1.4.1 \ - --hash=sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19 \ - --hash=sha256:b7332de6e9375593a29ba9eee1e6ecfeb3eb2043e2e19a13b4b71da73ff35540 +starlette==1.6.0 \ + --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \ + --hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b # via # apitally # fastapi @@ -2039,9 +2179,9 @@ typing-extensions==4.16.0 \ # pygeoif # sqlalchemy # typing-inspection -typing-inspection==0.4.2 \ - --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ - --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 +typing-inspection==0.4.4 \ + --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 # via # fastapi # ocotilloapi @@ -2068,9 +2208,9 @@ utm==0.9.0 \ --hash=sha256:1c8ffa6032631379374ceef05e6fea0ad42e9f09be0c3f91f7cc1b23f27be8a7 \ --hash=sha256:767592281e457dfacd71323ac69ff38e2f290d74526af91ca0924637de0e1d53 # via ocotilloapi -uvicorn==0.52.1 \ - --hash=sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd \ - --hash=sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a +uvicorn==0.52.3 \ + --hash=sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c \ + --hash=sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58 # via ocotilloapi werkzeug==3.1.8 \ --hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \ diff --git a/uv.lock b/uv.lock index 4966d646c..e3dbf53cd 100644 --- a/uv.lock +++ b/uv.lock @@ -137,16 +137,16 @@ wheels = [ [[package]] name = "alembic" -version = "1.19.0" +version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/01/a48dab7827ac4421272399f7ed9a2ec17edd12c8bcde4417bd7b6821b71a/alembic-1.19.0.tar.gz", hash = "sha256:6487c612fc719dcfa22b17d2dd5b2b458929641e6aa2f0b65b135727f5e6d501", size = 2069906, upload-time = "2026-08-04T18:57:04.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/2b/e4153978368de59918115c9e01d3ebf58a558a7285efa7e960c383c4b59a/alembic-1.19.1.tar.gz", hash = "sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648", size = 2070816, upload-time = "2026-08-08T16:32:01.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/79/59ab6f1fe72eee229de91aa393225389a85df12a0fbbbfa264efcf6d7872/alembic-1.19.0-py3-none-any.whl", hash = "sha256:cf839d3849116aab3cc047e09c6968b9bd6b2fde61b6bb7c1e97352fe5503580", size = 265738, upload-time = "2026-08-04T18:57:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/20/89/e62cc37b69ad357cc8ecd6e7367f5245f523d3cbb338a66197212bdf6749/alembic-1.19.1-py3-none-any.whl", hash = "sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be", size = 265946, upload-time = "2026-08-08T16:32:03.153Z" }, ] [[package]] @@ -495,50 +495,117 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, - { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, - { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, - { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, - { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, - { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, - { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, - { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, - { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, - { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, - { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, - { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, - { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, - { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, - { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, - { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, - { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, - { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/31/4971872b3ed8715346231fb6eb4da8fcba65a4143c189db151ee28a2812b/charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e", size = 169295, upload-time = "2026-08-12T14:35:31.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/be/cc7b7b6fc41984902c0d31b06f5d9297e67705c1dae9352608e5540fad09/charset_normalizer-3.5.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144", size = 211050, upload-time = "2026-08-12T14:32:47.81Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ae/e3ec8f17313609f43f7b323012fdb1ee37b83432277ca4eceba83e00366c/charset_normalizer-3.5.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f", size = 222768, upload-time = "2026-08-12T14:32:49.027Z" }, + { url = "https://files.pythonhosted.org/packages/c5/50/9f9c0d7ccc1512d49e27a0e7c12c58ec71dfe91698fa4326f058c33e1f1b/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc", size = 193907, upload-time = "2026-08-12T14:32:50.414Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/cfe7b745ef7f4c3b7214581955b5a0869ba2ac551a58fc11036281ae167c/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991", size = 197135, upload-time = "2026-08-12T14:32:51.605Z" }, + { url = "https://files.pythonhosted.org/packages/28/55/30fafdcfca9ba616bc394240545e4cd52f4f66dea43ded81b7d2d5274fde/charset_normalizer-3.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64", size = 339892, upload-time = "2026-08-12T14:32:52.82Z" }, + { url = "https://files.pythonhosted.org/packages/f4/08/bdca5fc2bdc36ee443673dc7d12b23885a5a7b282bef85a1a4c3b325b40e/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b", size = 239439, upload-time = "2026-08-12T14:32:54.058Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9e/506c8d7a7722bba7c8cdd78c1b5ef23bda92bfbe0b3e28ea84673d519a0f/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee", size = 227896, upload-time = "2026-08-12T14:32:55.326Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/dd828f2b1d6f4bf10821b9a74d866be05ffcdbfcddfc501d6fe6428762a7/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d", size = 262548, upload-time = "2026-08-12T14:32:56.484Z" }, + { url = "https://files.pythonhosted.org/packages/be/81/196d26f6bd78b93e0d451b69082a71027ceeddd4b0be9170b81bb038f824/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f", size = 259986, upload-time = "2026-08-12T14:32:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/5b964a1eb0f9075ecd45083eeb21aaec215334f98bac3d400302ea73875d/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be", size = 249853, upload-time = "2026-08-12T14:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b4/aee3a9d82edd0e931091ef3e9f03e46491ae3590e96e998d0975dadbe17c/charset_normalizer-3.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9", size = 244217, upload-time = "2026-08-12T14:33:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/22/3e/33f72ca11c1b619b220fd9f35905ebd171cbd0e0470f2357e467b9e861ee/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715", size = 241307, upload-time = "2026-08-12T14:33:01.589Z" }, + { url = "https://files.pythonhosted.org/packages/78/27/6029dccba958621c7f3a65136f87c5512d712aef9e890f09512cc171bd03/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08", size = 233305, upload-time = "2026-08-12T14:33:02.866Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/691c967be459153fe9faf49bf78bc95639ef8bf6dd008f38cc6389a349eb/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2", size = 263465, upload-time = "2026-08-12T14:33:04.166Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2d/9202221be5c90b2a835924191e362690ed8dc8c7d6606100c2bd03fe0f8c/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d", size = 245060, upload-time = "2026-08-12T14:33:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9a/298772fd0a0cbccadf36451a1cd7eef4b66a11e99b4a7f6fafc47cc62c75/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d", size = 261091, upload-time = "2026-08-12T14:33:06.475Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/1a11fe66e555dbe2f5714ade6ba74fa29edc9155d9cf1001d4d6ed096aa7/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888", size = 251857, upload-time = "2026-08-12T14:33:07.784Z" }, + { url = "https://files.pythonhosted.org/packages/17/fc/73b817e8af3f1d25ec5cf458d405abba5a144cf9812238a61530f5eac186/charset_normalizer-3.5.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d", size = 139745, upload-time = "2026-08-12T14:33:09.123Z" }, + { url = "https://files.pythonhosted.org/packages/b3/fb/ddb66303c86f7dc5043a457dad9fa82b4d6d0cb97094f9bcdde21693fd58/charset_normalizer-3.5.0-cp313-cp313-win32.whl", hash = "sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e", size = 177217, upload-time = "2026-08-12T14:33:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/fb/88/6018cc8d76ea2b7cb02918f37e23e86c261d1a102713d7e88d2cfb8b211c/charset_normalizer-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a", size = 198896, upload-time = "2026-08-12T14:33:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/04c0bbfc8d9abf91959f7a3d207d45cbf63a8116984caae2381890019bb5/charset_normalizer-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b", size = 179193, upload-time = "2026-08-12T14:33:12.726Z" }, + { url = "https://files.pythonhosted.org/packages/43/14/d098868dac5ff27e0258f548b1c74c6484be528384965d8fcf8fc6a4011d/charset_normalizer-3.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2", size = 211664, upload-time = "2026-08-12T14:33:14.153Z" }, + { url = "https://files.pythonhosted.org/packages/e7/da/a944b32a46601ae5a4c3499e8d64ecd14fe82313f00da74dcdf00273a0b4/charset_normalizer-3.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636", size = 224375, upload-time = "2026-08-12T14:33:15.472Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/eabb5996be2f529744755e7b2fc9396eff4a64961f034e7fd49d54b9afb2/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b", size = 194364, upload-time = "2026-08-12T14:33:16.607Z" }, + { url = "https://files.pythonhosted.org/packages/78/65/4ad3c5be108930310d8003f5602861d5b89f728293b9f09c3a4837f7ba10/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45", size = 197643, upload-time = "2026-08-12T14:33:17.88Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8fee3201b98d52289be60a775797d69be05a04fb6cfb48c1587dad33e649/charset_normalizer-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6", size = 341384, upload-time = "2026-08-12T14:33:19.239Z" }, + { url = "https://files.pythonhosted.org/packages/a7/dd/9e757101d1f76c35c0643684ba499ac3a181fb2b264c68174bf727d627e8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73", size = 241637, upload-time = "2026-08-12T14:33:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/7857023015400bc4aa0a82fbcca29fa2dc7ec25f971a130764cb2dc7a589/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8", size = 226170, upload-time = "2026-08-12T14:33:21.773Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/8b52935b304f7b6bbf33151ed2b75266b09aa4b6f8f04230d948885b2577/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b", size = 265093, upload-time = "2026-08-12T14:33:22.999Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/6e838f6bb059f2c0afc60a4e7f294252f043c254656ad4114c50302cae4d/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd", size = 262789, upload-time = "2026-08-12T14:33:24.214Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/189b27e51fddc9d6b3695331da0e31792c1d88b953ad854e57f06e9b2cc8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c", size = 250580, upload-time = "2026-08-12T14:33:25.707Z" }, + { url = "https://files.pythonhosted.org/packages/ac/55/64854e99b25841f83e8e37d9df2f3d1f96f693439f80e5fabd542a7e47ab/charset_normalizer-3.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458", size = 245008, upload-time = "2026-08-12T14:33:26.971Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/7720c904fa635d4260b4dced6029cf3d298c57b26741365d5a8d28c54043/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700", size = 243892, upload-time = "2026-08-12T14:33:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/70/50/7bfcb327631d4870c720872b548745f6ec8baa044d51c21b5d1d32ac4e3a/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a", size = 230996, upload-time = "2026-08-12T14:33:29.511Z" }, + { url = "https://files.pythonhosted.org/packages/24/51/40c45d6d940c04005ed721aa54bdebf1ebb2930f8a2ae537e8d60484fb27/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec", size = 265834, upload-time = "2026-08-12T14:33:30.689Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/ef7a227ef89d215b47f9df79c3966610b17faa13bb2f236989207a631622/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3", size = 245544, upload-time = "2026-08-12T14:33:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/37/a9/a4ca9156964ded61c7718eba410ce11be2fd2b263fda4bcf08367b6578cd/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0", size = 264110, upload-time = "2026-08-12T14:33:33.13Z" }, + { url = "https://files.pythonhosted.org/packages/38/6a/838364bb8702229c6e5f8b23f80ff0f052a12dfaf3113a12fd6acbe92a44/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb", size = 252303, upload-time = "2026-08-12T14:33:34.98Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5f/d88032edce951f499a2321cf7ae0d35a043c74be12bc22d81084cc7afbcc/charset_normalizer-3.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053", size = 139964, upload-time = "2026-08-12T14:33:36.195Z" }, + { url = "https://files.pythonhosted.org/packages/37/ae/1c4a46b6b00d1c34d2ee355ef99ad6173674166800d1af0f05f85028d513/charset_normalizer-3.5.0-cp314-cp314-win32.whl", hash = "sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e", size = 179790, upload-time = "2026-08-12T14:33:37.356Z" }, + { url = "https://files.pythonhosted.org/packages/01/51/f94dcf34fa8eba48c1fb89b6490a5f1426e19488fe5f38aac6c648c99057/charset_normalizer-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4", size = 203723, upload-time = "2026-08-12T14:33:38.639Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ba/91d386870b5d9e4b0d8c4034f63877cc2e47b99c81ef05f3e6d42bf9a53f/charset_normalizer-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834", size = 183423, upload-time = "2026-08-12T14:33:39.899Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c9/534ecb17b7fb95f9052c4a44cf316316a27d4a8f73e8475ff55e778dcdd7/charset_normalizer-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6", size = 368967, upload-time = "2026-08-12T14:33:41.093Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/ad7c3242d7fe55cd55126c22c65cb1b49779782cdf8932fd01d12232d86a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f", size = 239478, upload-time = "2026-08-12T14:33:42.428Z" }, + { url = "https://files.pythonhosted.org/packages/7e/62/77f0b850048e430fc350ec58876b0c020f5c8d0d3956fd1a4d6ae2fa292f/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1", size = 227036, upload-time = "2026-08-12T14:33:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ba/47d951e1a51dddbaad0a1410baf49fb1d897ceb00281568f1183b79bce9a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa", size = 260772, upload-time = "2026-08-12T14:33:44.96Z" }, + { url = "https://files.pythonhosted.org/packages/b0/61/8c7ff4c81b2a88271126acf4b83ab3e31f6d63868b0f01d331eaa0f9cb67/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4", size = 259273, upload-time = "2026-08-12T14:33:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fd/36129689be08dc287b951306946657ff70d76e287dd57018861f86d0e474/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6", size = 248086, upload-time = "2026-08-12T14:33:47.54Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f8/bcae67f994c8fd31dda445e5ebf84045823c31443fe46f0e9ee6aca99aa0/charset_normalizer-3.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0", size = 242671, upload-time = "2026-08-12T14:33:48.746Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/0472cdad1061c2f0e4d3aee29973eb6e81bb8fe256ff2860cf115b15f1c9/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0", size = 241311, upload-time = "2026-08-12T14:33:50.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/89/04a03de5d27c77c624d9fcf6287073754bd438df1b58cb7d030c57c2824d/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19", size = 229898, upload-time = "2026-08-12T14:33:51.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/a2/639c4278adcb7ed1f4db608dd9ac19b6774fa2285a96b1c0bdb9c124ccbd/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50", size = 262852, upload-time = "2026-08-12T14:33:52.924Z" }, + { url = "https://files.pythonhosted.org/packages/9d/95/02e34c97bedfd0c5574efb9179c850591acc7f967ba039ed8dd29d332b73/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa", size = 242913, upload-time = "2026-08-12T14:33:54.18Z" }, + { url = "https://files.pythonhosted.org/packages/a3/64/0946aeab6462dad9f160a50dfb4704d3f58a5ee708f085abc2105fbbff0c/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9", size = 257938, upload-time = "2026-08-12T14:33:55.802Z" }, + { url = "https://files.pythonhosted.org/packages/79/77/36787d41ead124746506a4425c729f4f17c68280af8a6a5baa0a598cae86/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40", size = 249467, upload-time = "2026-08-12T14:33:57.081Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/d9f6c5589cd24198d4ce6cd2948191c18e657272f433e5a00d258d9f5c22/charset_normalizer-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6", size = 190624, upload-time = "2026-08-12T14:33:58.449Z" }, + { url = "https://files.pythonhosted.org/packages/6c/81/43e0584a802051a22c725795ebe1df78263abc7de858eef6cdc9b36637e9/charset_normalizer-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3", size = 215902, upload-time = "2026-08-12T14:33:59.753Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/af6a1160fef0eac4510d035241e11eccf78e5350e4cd4de79e79fe02a5e5/charset_normalizer-3.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897", size = 193452, upload-time = "2026-08-12T14:34:01.017Z" }, + { url = "https://files.pythonhosted.org/packages/42/a4/dee470afb7a55c4f78b6fef37306c51fed17ebf94dbe530798c91d394350/charset_normalizer-3.5.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce", size = 341595, upload-time = "2026-08-12T14:34:02.4Z" }, + { url = "https://files.pythonhosted.org/packages/6a/32/9c3126dc429c6d9d7f79c52681a7c4453ed20a26267c9a8275d7ab620aba/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757", size = 242177, upload-time = "2026-08-12T14:34:03.741Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9d/5b616a887301ff4cc0916b39ba44257390d3da80deeed6e8b6f2f26b14a8/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396", size = 236730, upload-time = "2026-08-12T14:34:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/d6d3e70be93ebe5fabef65e4c7ac113e1d1705cbaeb5fb72467e713aca17/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44", size = 265158, upload-time = "2026-08-12T14:34:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/3f1fafa87e2643257474f9c4eec609f2193a61d907dce7dd4f3f2390ebd5/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293", size = 262931, upload-time = "2026-08-12T14:34:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/0a/df/ebeb224a949d91829e5e114c6b64372a3c792b00762a9e951ce416f3a32d/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85", size = 251388, upload-time = "2026-08-12T14:34:08.949Z" }, + { url = "https://files.pythonhosted.org/packages/a0/64/9a6ce2e7acc5cf1b4636f78f82e89ff581e06a0216a40678b28bd4d832c4/charset_normalizer-3.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1", size = 251821, upload-time = "2026-08-12T14:34:10.138Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b1/6e69b8056f615e5ccff6b91ca16db2d47922251f016821a300c115267fef/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78", size = 244507, upload-time = "2026-08-12T14:34:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/0f/34/02c15d6a0aa6b934dcdc136b111da63ae857b9fd51cf5505b0736337c2eb/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b", size = 240951, upload-time = "2026-08-12T14:34:12.991Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/0fe893d3e1c7d111280bd6c4bd4c1e431487a1124a1bcbce78dfeda3a3a8/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f", size = 266162, upload-time = "2026-08-12T14:34:14.232Z" }, + { url = "https://files.pythonhosted.org/packages/55/ea/eca03527307670f5d102c295671a800c404ca958cf94fefd10fc963a72f0/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80", size = 251835, upload-time = "2026-08-12T14:34:15.48Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/fee5633081e595fe9e191df6f215106c791ad596eddf5e41e39b8ea0f2e2/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9", size = 264314, upload-time = "2026-08-12T14:34:16.679Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fb/17f47ae6ca35b562fb6e6f4b05f7aec6034217353eb4a23aaa3566dc7340/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4", size = 253194, upload-time = "2026-08-12T14:34:17.975Z" }, + { url = "https://files.pythonhosted.org/packages/59/88/f2b0f7ebb92493e925889ff29239b3b0073ffafd91230dbfc69e5cf9389c/charset_normalizer-3.5.0-cp315-cp315-win32.whl", hash = "sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731", size = 179800, upload-time = "2026-08-12T14:34:19.409Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f0/afb5bfdea52fd943b1960403847a276b8e900c6e4cd6a38752321b4eda64/charset_normalizer-3.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040", size = 203726, upload-time = "2026-08-12T14:34:20.656Z" }, + { url = "https://files.pythonhosted.org/packages/fc/71/219783eb691aa2ec879c0e521afdfe2b826f9678eed51b9c039d03e0db2b/charset_normalizer-3.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e", size = 183428, upload-time = "2026-08-12T14:34:21.975Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/14aef3b9f80f2593c039d897e89034635b9eb0eb44b6ce5173bbd79ff338/charset_normalizer-3.5.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a", size = 368728, upload-time = "2026-08-12T14:34:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/10/fc/b249466ddbbeffa448b6597631e9091d1f01b5132ff8e7a0e21a6eb72b63/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698", size = 240925, upload-time = "2026-08-12T14:34:24.504Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b9/c17e72aaa1b3e1ca6c184e8025cf138ed492d01a54f85286ff7d31253a4b/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075", size = 234932, upload-time = "2026-08-12T14:34:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/f84ef0966bbe216f71029e34e7fa425a16b1682e2a40265e679dedf2b655/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74", size = 261733, upload-time = "2026-08-12T14:34:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/3b/73/3e887fa0781a395339355ed934ab6561ceb5bb52574160f070224039c630/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4", size = 258460, upload-time = "2026-08-12T14:34:28.431Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/52ebd9849bf9e35d0b21fff115cb6543162a8e1f2f564e8f87121a336b8c/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258", size = 249894, upload-time = "2026-08-12T14:34:29.698Z" }, + { url = "https://files.pythonhosted.org/packages/85/f3/9366492b8a5fe0187de282e001d61345740cf79eb4a5f20181d769be02b5/charset_normalizer-3.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4", size = 249540, upload-time = "2026-08-12T14:34:30.96Z" }, + { url = "https://files.pythonhosted.org/packages/0b/af/28bb5e5dbd3e67cb9196a62781ac2b6d79492f4fc7a069b6ca7d6d6c8d58/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2", size = 242734, upload-time = "2026-08-12T14:34:32.481Z" }, + { url = "https://files.pythonhosted.org/packages/26/d6/7ccfa62b53b40fc06b2d3504825aa400764740bd10cf248fdc4272441b93/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d", size = 239580, upload-time = "2026-08-12T14:34:33.916Z" }, + { url = "https://files.pythonhosted.org/packages/0b/82/71c0c9b046697b8da66b3acefa8d5f92d00a9ef433ad7c3522b971d0369a/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e", size = 263281, upload-time = "2026-08-12T14:34:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/d6/01/d027583c869f40ba980c1c76994adbd522c360a6327e72beb44d7c267385/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394", size = 250027, upload-time = "2026-08-12T14:34:36.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e9/6475d739e0ec8bb1236e06263dc3affaffdf947d8114ad27024932f325da/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0", size = 257547, upload-time = "2026-08-12T14:34:38.277Z" }, + { url = "https://files.pythonhosted.org/packages/a5/60/d1f502fcaa048a2aca3ab80bfef8407659c131e4f1792fa805fec14b4960/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97", size = 251718, upload-time = "2026-08-12T14:34:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/76343dcf4381a698807ff8a20d89f66bbdd9f6222b0b17740f77ab764335/charset_normalizer-3.5.0-cp315-cp315t-win32.whl", hash = "sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7", size = 190757, upload-time = "2026-08-12T14:34:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ea/d18147626a1667cc773c42104ab155a4ca5d6d4d174b7a35e01062213ea5/charset_normalizer-3.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775", size = 215431, upload-time = "2026-08-12T14:34:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/47/21/4869598aae0872d94faa5933918a4fe37ab2c5af9d095786e241f9506fed/charset_normalizer-3.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03", size = 193205, upload-time = "2026-08-12T14:34:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f3/7b523d807cb5e73562ef8acf21d39cdb9d704955327362c781bc3478a73d/charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5", size = 330840, upload-time = "2026-08-12T14:34:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/f0/de/fc68978fe78ca97063c96d764e41ff92ca639948f319271e0ff450e577a2/charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3", size = 251862, upload-time = "2026-08-12T14:34:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cb/82b41a0ab7fb1a88065f1d78ad32696ad88ea3fe8e25b8189d08833938de/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3", size = 239484, upload-time = "2026-08-12T14:34:47.869Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0c/19608b631f4538f908098d4a2d56a8f79a665e27cc58e9d90479761a9227/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438", size = 230602, upload-time = "2026-08-12T14:34:49.265Z" }, + { url = "https://files.pythonhosted.org/packages/29/db/f648eb30e14eba301aed61e11672156f137905c1bdbb530151abe8065943/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a", size = 259208, upload-time = "2026-08-12T14:34:50.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e0/ed2c8bdbac484d69614d6993143aeb6cb0f4dd1561c883402517b623c8ef/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539", size = 253659, upload-time = "2026-08-12T14:34:52.11Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/b4907cb9ec5b521d9d024ced13611240b86ef065c2eb15b3ad2334dc9940/charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706", size = 248821, upload-time = "2026-08-12T14:34:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/12/b2/e2d1abcfbc05822f0030869efb4e9f8a3658e13b4821796d4b62da917327/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26", size = 240271, upload-time = "2026-08-12T14:34:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f9/4ba127ad610542fa3eabfa41c45bf12d357860a815b3566374ec0188e213/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3", size = 232155, upload-time = "2026-08-12T14:34:56.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/68/40613182366d00bd6dbd5f6c84a926cbd120960e038a8269e9ae7d782762/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e", size = 259674, upload-time = "2026-08-12T14:34:57.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/53/4574a14fa4c9de4a6c9f31725354bfa40b67f653e6d594ce1654f9a41b32/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49", size = 246122, upload-time = "2026-08-12T14:34:59.337Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/bd8030d13d92c058ca7b2b9615bbb3169569e144db64d65c149cd45abf5e/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45", size = 255221, upload-time = "2026-08-12T14:35:00.71Z" }, + { url = "https://files.pythonhosted.org/packages/77/9d/10ecd3bcbe2666b3d4d4026c97b48f73990682815db516052a1e8f4a31c5/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8", size = 253450, upload-time = "2026-08-12T14:35:02.217Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/d044c4872c0938a84f87b5027a698c0e61bacfc5c3551a4e749ca9b7bc5c/charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516", size = 173594, upload-time = "2026-08-12T14:35:03.845Z" }, + { url = "https://files.pythonhosted.org/packages/10/6b/6046773901f1944b9a89436351529811ee958afc7b774563be9d74a6f0c3/charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74", size = 198959, upload-time = "2026-08-12T14:35:05.187Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/b57708ac92aefc8e8389d51d5178129b81f03196da61ee2c23e687b8178a/charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca", size = 267055, upload-time = "2026-08-12T14:35:06.533Z" }, + { url = "https://files.pythonhosted.org/packages/22/c7/754d09943a616937df61e4ba367c409ded2a987e872972098d51a6fcf73b/charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea", size = 67943, upload-time = "2026-08-12T14:35:30.363Z" }, ] [[package]] @@ -1092,59 +1159,59 @@ wheels = [ [[package]] name = "greenlet" -version = "3.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, - { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, - { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" }, - { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, - { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, - { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, - { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, - { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, - { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, - { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" }, - { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, - { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" }, - { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, - { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, - { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, - { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, - { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, - { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, - { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, - { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, - { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" }, - { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, - { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, - { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, - { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, - { url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" }, - { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, - { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, - { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, - { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" }, - { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, - { url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" }, - { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, - { url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" }, - { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, - { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, - { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, ] [[package]] @@ -1542,42 +1609,64 @@ wheels = [ [[package]] name = "numpy" -version = "2.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, - { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, - { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, - { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, - { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, - { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, - { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, - { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, - { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, - { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, - { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, - { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, - { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, - { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, - { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, - { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, - { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, - { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, - { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, ] [[package]] @@ -1707,7 +1796,7 @@ requires-dist = [ { name = "aiohttp", specifier = "==3.14.3" }, { name = "aiosignal", specifier = "==1.4.0" }, { name = "aiosqlite", specifier = "==0.22.1" }, - { name = "alembic", specifier = "==1.19.0" }, + { name = "alembic", specifier = "==1.19.1" }, { name = "annotated-types", specifier = "==0.8.0" }, { name = "anyio", specifier = "==4.14.2" }, { name = "apitally", extras = ["fastapi"], specifier = "==0.25.1" }, @@ -1720,7 +1809,7 @@ requires-dist = [ { name = "cachetools", specifier = "==7.1.7" }, { name = "certifi", specifier = "==2026.7.22" }, { name = "cffi", specifier = "==2.1.1" }, - { name = "charset-normalizer", specifier = "==3.4.9" }, + { name = "charset-normalizer", specifier = "==3.5.0" }, { name = "click", specifier = "==8.4.2" }, { name = "cloud-sql-python-connector", specifier = "==1.21.0" }, { name = "cryptography", specifier = "==50.0.0" }, @@ -1738,7 +1827,7 @@ requires-dist = [ { name = "google-crc32c", specifier = "==1.8.0" }, { name = "google-resumable-media", specifier = "==2.10.1" }, { name = "googleapis-common-protos", specifier = "==1.75.1" }, - { name = "greenlet", specifier = "==3.5.4" }, + { name = "greenlet", specifier = "==3.5.5" }, { name = "gunicorn", specifier = "==23.0.0" }, { name = "h11", specifier = "==0.16.0" }, { name = "httpcore", specifier = "==1.0.9" }, @@ -1749,12 +1838,12 @@ requires-dist = [ { name = "mako", specifier = "==1.4.1" }, { name = "markupsafe", specifier = "==3.0.3" }, { name = "multidict", specifier = "==6.7.1" }, - { name = "numpy", specifier = "==2.5.1" }, + { name = "numpy", specifier = "==2.5.2" }, { name = "packaging", specifier = "==26.3" }, { name = "pandas", specifier = "==2.3.2" }, { name = "pandas-stubs", specifier = "~=2.3.2" }, { name = "pg8000", specifier = "==1.31.5" }, - { name = "phonenumbers", specifier = "==9.0.36" }, + { name = "phonenumbers", specifier = "==9.0.37" }, { name = "pillow", specifier = "==12.3.0" }, { name = "pluggy", specifier = "==1.6.0" }, { name = "propcache", specifier = "==0.5.2" }, @@ -1778,24 +1867,24 @@ requires-dist = [ { name = "pytz", specifier = "==2026.3.post1" }, { name = "requests", specifier = "==2.34.2" }, { name = "rsa", specifier = "==4.9.1" }, - { name = "scramp", specifier = "==1.4.16" }, - { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.66.1" }, + { name = "scramp", specifier = "==1.4.17" }, + { name = "sentry-sdk", extras = ["fastapi"], specifier = "==2.68.0" }, { name = "shapely", specifier = "==2.1.2" }, { name = "six", specifier = "==1.17.0" }, { name = "sniffio", specifier = "==1.3.1" }, - { name = "sqlalchemy", specifier = "==2.0.51" }, + { name = "sqlalchemy", specifier = "==2.0.52" }, { name = "sqlalchemy-continuum", specifier = "==1.7.0" }, { name = "sqlalchemy-searchable", specifier = "==2.1.0" }, { name = "sqlalchemy-utils", specifier = "==0.42.1" }, { name = "sqlparse", specifier = ">=0.6.0" }, - { name = "starlette", specifier = "==1.4.1" }, + { name = "starlette", specifier = "==1.6.0" }, { name = "typer", specifier = "==0.27.1" }, { name = "typing-extensions", specifier = "==4.16.0" }, - { name = "typing-inspection", specifier = "==0.4.2" }, + { name = "typing-inspection", specifier = "==0.4.4" }, { name = "tzdata", specifier = "==2026.3" }, { name = "urllib3", specifier = "==2.7.0" }, { name = "utm", specifier = "==0.9.0" }, - { name = "uvicorn", specifier = "==0.52.1" }, + { name = "uvicorn", specifier = "==0.52.3" }, { name = "yarl", specifier = "==1.24.5" }, ] @@ -1809,7 +1898,7 @@ dev = [ { name = "black", specifier = ">=26.5.1" }, { name = "faker", specifier = ">=25.0.0" }, { name = "flake8", specifier = ">=7.3.0" }, - { name = "pre-commit", specifier = ">=4.6.1" }, + { name = "pre-commit", specifier = ">=4.6.2" }, { name = "pyhamcrest", specifier = ">=2.0.3" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-cov", specifier = ">=6.2.1" }, @@ -1964,11 +2053,11 @@ wheels = [ [[package]] name = "phonenumbers" -version = "9.0.36" +version = "9.0.37" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/21/3f1561d3527b27121d6872aeecb6df2a8de8e11af9c36f5a0bd3483d2655/phonenumbers-9.0.36.tar.gz", hash = "sha256:60ca2a6870d2532d6e960105790e85e9d69270ede81746d10cf57f5e437b93ec", size = 2307271, upload-time = "2026-08-01T06:13:44.954Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/36/ee5b6d7ccc28cb7848e8dfefce94df9178b6318ed56adab97550b6ba99ec/phonenumbers-9.0.37.tar.gz", hash = "sha256:8923ab413c575b27da2815c9bef48290181aab565398b5e7e23cc7ace212a293", size = 2312364, upload-time = "2026-08-14T05:29:13.533Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/5d/74fbcb16b29927bf378f4ec4d47c7b48488b93e1f1af64b26544daf1d55f/phonenumbers-9.0.36-py2.py3-none-any.whl", hash = "sha256:c16a5b95178345ec2df1954578a4ac09e937443b2ee992dd7b15a8e8c81f4acf", size = 2595528, upload-time = "2026-08-01T06:13:41.591Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/544cb901964e8bfc3c8d215d5620e63a009b32437efaec905fdefdf59e26/phonenumbers-9.0.37-py2.py3-none-any.whl", hash = "sha256:55ae8243c1e2fe8ae2548d979e57155d9ae0f6084f8a1d6884050b143aff0a7c", size = 2595712, upload-time = "2026-08-14T05:29:10.464Z" }, ] [[package]] @@ -2053,7 +2142,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.6.1" +version = "4.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -2062,9 +2151,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/3a/ddb78f32a0814e66b18a099377a106a2dcdce92d86a034d69d65df9b256e/pre_commit-4.6.1.tar.gz", hash = "sha256:03e809865c7d178b9979d06c761fcbfe6808fdaded8581a745bb110e52050421", size = 198646, upload-time = "2026-07-21T20:56:58.225Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl", hash = "sha256:0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717", size = 226186, upload-time = "2026-07-21T20:56:57.064Z" }, + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, ] [[package]] @@ -2873,27 +2962,27 @@ wheels = [ [[package]] name = "scramp" -version = "1.4.16" +version = "1.4.17" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asn1crypto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/3f/dc633be307f4f9f5e41f9374da6bb2b4f0332fd388b1d92306909a308bcf/scramp-1.4.16.tar.gz", hash = "sha256:836bcecdc8843af76b35e11adbe818e3d7934d0d25aa48fd2e8cde362a2c6a9b", size = 21210, upload-time = "2026-08-06T16:48:56.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/76/6db02f36db58a7d009e90f51e961bcc3c44a1c930a744f026e30e791989b/scramp-1.4.17.tar.gz", hash = "sha256:28970f29ebc33df47f9975c805e5e5a360effe5b31045e607d64b3b60370dba1", size = 21291, upload-time = "2026-08-07T17:19:40.827Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/f0/5441d2bac230fa9a197c081672b57bdf2641e3988dddd39c687a3ad5267b/scramp-1.4.16-py3-none-any.whl", hash = "sha256:9e6148fca008ab6c6ce84658aeefd03d5d7f269d32fe08d07e134dae089e937a", size = 16083, upload-time = "2026-08-06T16:48:55.088Z" }, + { url = "https://files.pythonhosted.org/packages/7f/99/0e372781210cd36b2f2727e5be3ea93066edad7edd6fa2dfdec3b3e28845/scramp-1.4.17-py3-none-any.whl", hash = "sha256:a4e3fd2e8169461a28a13777a166d3da94274454f0714a7d3023fee124474ac8", size = 16131, upload-time = "2026-08-07T17:19:39.591Z" }, ] [[package]] name = "sentry-sdk" -version = "2.66.1" +version = "2.68.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/6f/d59cad0889d15fde85254cf58e701484de3f3f0406003b3197746910b19b/sentry_sdk-2.66.1.tar.gz", hash = "sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc", size = 940543, upload-time = "2026-07-22T12:26:54.553Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/94/23b7dd072acb9628907bd3f4fbf61794a7b12a9db8f33c1276f70ae5ac92/sentry_sdk-2.68.0.tar.gz", hash = "sha256:648c58e9887311a03470a41539e24bdbbf64a30ca4f5336f7e3dcc87276400b3", size = 1008854, upload-time = "2026-08-13T09:06:21.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl", hash = "sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6", size = 505555, upload-time = "2026-07-22T12:26:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/7d/9b/e2421d08956d0bc4691d995393d835e563886bff499d8fb10fdefae85a8d/sentry_sdk-2.68.0-py3-none-any.whl", hash = "sha256:538e56c2d03679d42f7c0cb5f1af73a7a510b00abc7e296c13ac49b107b713a4", size = 518670, upload-time = "2026-08-13T09:06:19.735Z" }, ] [package.optional-dependencies] @@ -2973,36 +3062,30 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.51" +version = "2.0.52" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, - { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, - { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, - { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, - { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, - { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, - { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, - { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, - { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, - { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, - { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, - { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, - { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, - { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, - { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, - { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, + { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, ] [[package]] @@ -3053,14 +3136,14 @@ wheels = [ [[package]] name = "starlette" -version = "1.4.1" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/3c/76d2fd1f1357ed0f0108d8a5aa233dcf16e2946a8559c84912fe08e01ac7/starlette-1.4.1.tar.gz", hash = "sha256:b7332de6e9375593a29ba9eee1e6ecfeb3eb2043e2e19a13b4b71da73ff35540", size = 2709041, upload-time = "2026-08-05T15:17:26.23Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/0a/67e95f21498de41433babf7b1db0eeab449eb58872dfb831b27747a70fd0/starlette-1.4.1-py3-none-any.whl", hash = "sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19", size = 74019, upload-time = "2026-08-05T15:17:24.357Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] [[package]] @@ -3107,14 +3190,14 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] [[package]] @@ -3167,15 +3250,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.52.1" +version = "0.52.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/28/64ca011edf31c715b4fad359c587ea52391aaffa125065695590241ff617/uvicorn-0.52.3.tar.gz", hash = "sha256:18857b9e6579300be55c91c0a1cfd37d9a2cf0cabea33b88275f199eb73b8b58", size = 100621, upload-time = "2026-08-13T16:50:02.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2b/ebd108734a8204c6b4b93c681c9a38c5273b3ccd5d129fee4ffc1d97772c/uvicorn-0.52.3-py3-none-any.whl", hash = "sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c", size = 79859, upload-time = "2026-08-13T16:50:01.323Z" }, ] [[package]] From 865b84d22357ebd630037b20c045b5e522af070d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:09:04 +0000 Subject: [PATCH 055/151] build(deps): bump gunicorn from 23.0.0 to 26.0.0 Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 23.0.0 to 26.0.0. - [Release notes](https://github.com/benoitc/gunicorn/releases) - [Commits](https://github.com/benoitc/gunicorn/compare/23.0.0...26.0.0) --- updated-dependencies: - dependency-name: gunicorn dependency-version: 26.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- requirements.txt | 6 +++--- uv.lock | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 07a73d554..267e99b56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ "google-resumable-media==2.10.1", "googleapis-common-protos==1.75.1", "greenlet==3.5.5", - "gunicorn==23.0.0", + "gunicorn==26.0.0", "h11==0.16.0", "httpcore==1.0.9", "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index f628a6a45..8d2abfc84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -912,9 +912,9 @@ greenlet==3.5.5 \ # via # ocotilloapi # sqlalchemy -gunicorn==23.0.0 \ - --hash=sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d \ - --hash=sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec +gunicorn==26.0.0 \ + --hash=sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc \ + --hash=sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf # via ocotilloapi h11==0.16.0 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ diff --git a/uv.lock b/uv.lock index e3dbf53cd..a615ad3ca 100644 --- a/uv.lock +++ b/uv.lock @@ -1247,14 +1247,14 @@ wheels = [ [[package]] name = "gunicorn" -version = "23.0.0" +version = "26.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, ] [[package]] @@ -1828,7 +1828,7 @@ requires-dist = [ { name = "google-resumable-media", specifier = "==2.10.1" }, { name = "googleapis-common-protos", specifier = "==1.75.1" }, { name = "greenlet", specifier = "==3.5.5" }, - { name = "gunicorn", specifier = "==23.0.0" }, + { name = "gunicorn", specifier = "==26.0.0" }, { name = "h11", specifier = "==0.16.0" }, { name = "httpcore", specifier = "==1.0.9" }, { name = "httpx", specifier = "==0.28.1" }, From 4c0099eb2f745312d5a43f5a9bb2ed25c32578b5 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 17 Aug 2026 17:11:14 -0700 Subject: [PATCH 056/151] feat(ogc): make /ogcapi-internal usable from ArcGIS Pro and QGIS The internal OGC mount accepted only `Authorization: Bearer `, which neither desktop GIS client can supply in practice. ArcGIS Pro cannot send a bearer token to an OGC API connection at all -- its dialog offers Basic ("Server Authentication"), Esri-portal OAuth, and custom request parameters, and Esri does not support token-secured OGC service connections. QGIS can send one, but shipped a regression (qgis/QGIS#60473) dropping the Authorization header on OGC API - Features requests. Neither client can refresh an Authentik access token before it expires. Accept two further credential transports alongside the existing bearer JWT: HTTP Basic (secret in either half of the credential) and a `?token=` query parameter. Both carry a static API key, stored as SHA-256 digests only in `INTERNAL_OGC_API_KEYS` as `label:sha256hex` entries and compared with hmac.compare_digest. A key is a pre-authorized stand-in for INTERNAL_OGC_GROUP; a bearer JWT is still checked for that group and still 403s without it. The `?token=` value is stripped from the query string before the request reaches pygeoapi, which would otherwise echo it into the `self` and `next` links of every response body. It remains in the App Engine request log, so the docs steer operators to Basic where the client supports it. Also fixes a blocker independent of auth: PYGEOAPI_INTERNAL_SERVER_URL is set in no deploy config, so `_internal_server_url()` fell through to its hardcoded `http://localhost:8000` default in every deployed environment and pygeoapi stamped that into every collection and items link. Both clients follow those links to page, so the first page loaded and page two walked off to localhost. Derive the URL from PYGEOAPI_SERVER_URL's application root instead, which every deploy already sets, keeping the explicit override for a split-host setup. Deployment sources the digest list from the Google Secret Manager secret `internal-ogc-api-keys`, matching how the Jira and Slack credentials are handled, rather than from a GitHub secret. The secret must exist in each project before the next deploy or get-secretmanager-secrets fails the job; see docs/internal-ogc-desktop-gis.md for the inert placeholder value. Revoking a key requires a redeploy. Smaller fixes in the same middleware: send WWW-Authenticate on 401, without which neither client prompts for credentials, and match the mount path on a segment boundary rather than a bare startswith. Co-Authored-By: Claude Opus 5 --- .env.example | 13 ++ .github/app.template.yaml | 7 + .github/workflows/CD_production.yml | 23 +-- .github/workflows/CD_staging.yml | 23 +-- .github/workflows/CD_testing.yml | 23 +-- CLAUDE.md | 12 ++ core/internal_ogc_auth.py | 192 +++++++++++++++++++++---- core/pygeoapi.py | 11 +- docs/internal-ogc-desktop-gis.md | 139 ++++++++++++++++++ tests/test_internal_ogc_auth.py | 209 ++++++++++++++++++++++++++++ 10 files changed, 600 insertions(+), 52 deletions(-) create mode 100644 docs/internal-ogc-desktop-gis.md create mode 100644 tests/test_internal_ogc_auth.py diff --git a/.env.example b/.env.example index 5fa1ad8ba..54c576b32 100644 --- a/.env.example +++ b/.env.example @@ -15,8 +15,21 @@ PYGEOAPI_POSTGRES_USER=your_username # above; only the mount path, runtime dir, and advertised server URL differ. PYGEOAPI_INTERNAL_MOUNT_PATH=/ogcapi-internal PYGEOAPI_INTERNAL_RUNTIME_DIR=/tmp/pygeoapi-internal +# Leave blank to derive from PYGEOAPI_SERVER_URL's application root. Only set +# this when the internal mount is served from a different host than /ogcapi. PYGEOAPI_INTERNAL_SERVER_URL= +# Static API keys for /ogcapi-internal, for desktop GIS clients that cannot +# refresh an Authentik access token (ArcGIS Pro, QGIS). Comma- or +# whitespace-separated `label:sha256hex` entries; the label is bookkeeping +# only. Blank means bearer-JWT access only. Mint one with: +# python -c "import secrets,hashlib;k=secrets.token_urlsafe(32);print(k,hashlib.sha256(k.encode()).hexdigest())" +# Give the first value to the user, put `label:` here. +# Deployed environments source this from the Secret Manager secret +# `internal-ogc-api-keys`, not from a GitHub secret. +# See docs/internal-ogc-desktop-gis.md. +INTERNAL_OGC_API_KEYS= + # Connection pool configuration for parallel transfers # pool_size: number of persistent connections to maintain # max_overflow: additional connections allowed during peak usage diff --git a/.github/app.template.yaml b/.github/app.template.yaml index bb44e584c..a0f67c7b9 100644 --- a/.github/app.template.yaml +++ b/.github/app.template.yaml @@ -34,6 +34,13 @@ env_variables: PYGEOAPI_POSTGRES_PASSWORD: |- ${PYGEOAPI_POSTGRES_PASSWORD} PYGEOAPI_SERVER_URL: "${PYGEOAPI_SERVER_URL}" + # Hashed static API keys for the authenticated /ogcapi-internal mount, as + # `label:sha256hex` entries, sourced from the Secret Manager secret + # `internal-ogc-api-keys`. Needed because ArcGIS Pro and QGIS cannot refresh + # an Authentik access token; see core/internal_ogc_auth.py and + # docs/internal-ogc-desktop-gis.md. Unset means bearer-JWT access only. + INTERNAL_OGC_API_KEYS: |- + ${INTERNAL_OGC_API_KEYS} CLOUD_SQL_IAM_AUTH: "${CLOUD_SQL_IAM_AUTH}" GCS_SERVICE_ACCOUNT_KEY: |- ${GCS_SERVICE_ACCOUNT_KEY} diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 39c5ff6b4..2df5ed439 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -71,11 +71,14 @@ jobs: with: credentials_json: ${{ secrets.CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY }} - # Feedback endpoint credentials live in Google Secret Manager, not - # GitHub secrets. The deploy service account needs - # roles/secretmanager.secretAccessor on these secrets. - - name: Fetch feedback secrets from Secret Manager - id: feedback-secrets + # Application credentials live in Google Secret Manager, not GitHub + # secrets. The deploy service account needs + # roles/secretmanager.secretAccessor on these secrets. Every secret + # listed here must already exist in the target project or the deploy + # fails -- see docs/internal-ogc-desktop-gis.md for the placeholder + # value to seed internal-ogc-api-keys with. + - name: Fetch application secrets from Secret Manager + id: app-secrets uses: 'google-github-actions/get-secretmanager-secrets@v3' with: secrets: |- @@ -83,6 +86,7 @@ jobs: jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url slack_edits_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-edits-webhook-url + internal_ogc_api_keys:${{ vars.GCP_PROJECT_ID }}/internal-ogc-api-keys - name: Run Alembic migrations on production database env: @@ -119,6 +123,7 @@ jobs: PYGEOAPI_POSTGRES_PORT: "${{ vars.PYGEOAPI_POSTGRES_PORT || '5432' }}" PYGEOAPI_POSTGRES_PASSWORD: "${{ secrets.PYGEOAPI_POSTGRES_PASSWORD }}" PYGEOAPI_SERVER_URL: "${{ vars.PYGEOAPI_SERVER_URL }}" + INTERNAL_OGC_API_KEYS: "${{ steps.app-secrets.outputs.internal_ogc_api_keys }}" CLOUD_SQL_IAM_AUTH: "true" GCS_SERVICE_ACCOUNT_KEY: "${{ secrets.GCS_SERVICE_ACCOUNT_KEY }}" GCS_BUCKET_NAME: "${{ vars.GCS_BUCKET_NAME }}" @@ -128,11 +133,11 @@ jobs: AUTHENTIK_TOKEN_URL: "${{ vars.AUTHENTIK_TOKEN_URL }}" APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}" JIRA_BASE_URL: "${{ vars.JIRA_BASE_URL || 'https://nmbgmr.atlassian.net' }}" - JIRA_EMAIL: "${{ steps.feedback-secrets.outputs.jira_email }}" - JIRA_API_TOKEN: "${{ steps.feedback-secrets.outputs.jira_api_token }}" + JIRA_EMAIL: "${{ steps.app-secrets.outputs.jira_email }}" + JIRA_API_TOKEN: "${{ steps.app-secrets.outputs.jira_api_token }}" JIRA_DEFAULT_PROJECT: "${{ vars.JIRA_DEFAULT_PROJECT || 'BDMS' }}" - SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_feedback_webhook_url }}" - SLACK_EDITS_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_edits_webhook_url }}" + SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.app-secrets.outputs.slack_feedback_webhook_url }}" + SLACK_EDITS_WEBHOOK_URL: "${{ steps.app-secrets.outputs.slack_edits_webhook_url }}" OCOTILLO_UI_BASE_URL: "${{ vars.OCOTILLO_UI_BASE_URL || 'https://ocotillo.newmexicowaterdata.org' }}" run: | export MAX_INSTANCES="10" diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index c1cbd243d..c5bf72b4a 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -36,11 +36,14 @@ jobs: with: credentials_json: ${{ secrets.CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY }} - # Feedback endpoint credentials live in Google Secret Manager, not - # GitHub secrets. The deploy service account needs - # roles/secretmanager.secretAccessor on these secrets. - - name: Fetch feedback secrets from Secret Manager - id: feedback-secrets + # Application credentials live in Google Secret Manager, not GitHub + # secrets. The deploy service account needs + # roles/secretmanager.secretAccessor on these secrets. Every secret + # listed here must already exist in the target project or the deploy + # fails -- see docs/internal-ogc-desktop-gis.md for the placeholder + # value to seed internal-ogc-api-keys with. + - name: Fetch application secrets from Secret Manager + id: app-secrets uses: 'google-github-actions/get-secretmanager-secrets@v3' with: secrets: |- @@ -48,6 +51,7 @@ jobs: jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url slack_edits_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-edits-webhook-url + internal_ogc_api_keys:${{ vars.GCP_PROJECT_ID }}/internal-ogc-api-keys - name: Run Alembic migrations on staging database env: @@ -79,6 +83,7 @@ jobs: PYGEOAPI_POSTGRES_PORT: "${{ vars.PYGEOAPI_POSTGRES_PORT || '5432' }}" PYGEOAPI_POSTGRES_PASSWORD: "${{ secrets.PYGEOAPI_POSTGRES_PASSWORD }}" PYGEOAPI_SERVER_URL: "${{ vars.PYGEOAPI_SERVER_URL }}" + INTERNAL_OGC_API_KEYS: "${{ steps.app-secrets.outputs.internal_ogc_api_keys }}" CLOUD_SQL_IAM_AUTH: "true" GCS_SERVICE_ACCOUNT_KEY: "${{ secrets.GCS_SERVICE_ACCOUNT_KEY }}" GCS_BUCKET_NAME: "${{ vars.GCS_BUCKET_NAME }}" @@ -88,11 +93,11 @@ jobs: AUTHENTIK_TOKEN_URL: "${{ vars.AUTHENTIK_TOKEN_URL }}" APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}" JIRA_BASE_URL: "${{ vars.JIRA_BASE_URL || 'https://nmbgmr.atlassian.net' }}" - JIRA_EMAIL: "${{ steps.feedback-secrets.outputs.jira_email }}" - JIRA_API_TOKEN: "${{ steps.feedback-secrets.outputs.jira_api_token }}" + JIRA_EMAIL: "${{ steps.app-secrets.outputs.jira_email }}" + JIRA_API_TOKEN: "${{ steps.app-secrets.outputs.jira_api_token }}" JIRA_DEFAULT_PROJECT: "${{ vars.JIRA_DEFAULT_PROJECT || 'BDMS' }}" - SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_feedback_webhook_url }}" - SLACK_EDITS_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_edits_webhook_url }}" + SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.app-secrets.outputs.slack_feedback_webhook_url }}" + SLACK_EDITS_WEBHOOK_URL: "${{ steps.app-secrets.outputs.slack_edits_webhook_url }}" OCOTILLO_UI_BASE_URL: "${{ vars.OCOTILLO_UI_BASE_URL || 'https://ocotillo-staging.newmexicowaterdata.org' }}" run: | export MAX_INSTANCES="10" diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index 0ed101d15..2a9a9dd2e 100644 --- a/.github/workflows/CD_testing.yml +++ b/.github/workflows/CD_testing.yml @@ -36,11 +36,14 @@ jobs: with: credentials_json: ${{ secrets.CLOUD_DEPLOY_SERVICE_ACCOUNT_KEY }} - # Feedback endpoint credentials live in Google Secret Manager, not - # GitHub secrets. The deploy service account needs - # roles/secretmanager.secretAccessor on these secrets. - - name: Fetch feedback secrets from Secret Manager - id: feedback-secrets + # Application credentials live in Google Secret Manager, not GitHub + # secrets. The deploy service account needs + # roles/secretmanager.secretAccessor on these secrets. Every secret + # listed here must already exist in the target project or the deploy + # fails -- see docs/internal-ogc-desktop-gis.md for the placeholder + # value to seed internal-ogc-api-keys with. + - name: Fetch application secrets from Secret Manager + id: app-secrets uses: 'google-github-actions/get-secretmanager-secrets@v3' with: secrets: |- @@ -48,6 +51,7 @@ jobs: jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url slack_edits_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-edits-webhook-url + internal_ogc_api_keys:${{ vars.GCP_PROJECT_ID }}/internal-ogc-api-keys - name: Run Alembic migrations on staging database env: @@ -79,6 +83,7 @@ jobs: PYGEOAPI_POSTGRES_PORT: "${{ vars.PYGEOAPI_POSTGRES_PORT || '5432' }}" PYGEOAPI_POSTGRES_PASSWORD: "${{ secrets.PYGEOAPI_POSTGRES_PASSWORD }}" PYGEOAPI_SERVER_URL: "${{ vars.PYGEOAPI_SERVER_URL }}" + INTERNAL_OGC_API_KEYS: "${{ steps.app-secrets.outputs.internal_ogc_api_keys }}" CLOUD_SQL_IAM_AUTH: "true" GCS_SERVICE_ACCOUNT_KEY: "${{ secrets.GCS_SERVICE_ACCOUNT_KEY }}" GCS_BUCKET_NAME: "${{ vars.GCS_BUCKET_NAME }}" @@ -88,11 +93,11 @@ jobs: AUTHENTIK_TOKEN_URL: "${{ vars.AUTHENTIK_TOKEN_URL }}" APITALLY_CLIENT_ID: "${{ vars.APITALLY_CLIENT_ID }}" JIRA_BASE_URL: "${{ vars.JIRA_BASE_URL || 'https://nmbgmr.atlassian.net' }}" - JIRA_EMAIL: "${{ steps.feedback-secrets.outputs.jira_email }}" - JIRA_API_TOKEN: "${{ steps.feedback-secrets.outputs.jira_api_token }}" + JIRA_EMAIL: "${{ steps.app-secrets.outputs.jira_email }}" + JIRA_API_TOKEN: "${{ steps.app-secrets.outputs.jira_api_token }}" JIRA_DEFAULT_PROJECT: "${{ vars.JIRA_DEFAULT_PROJECT || 'BDMS' }}" - SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_feedback_webhook_url }}" - SLACK_EDITS_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_edits_webhook_url }}" + SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.app-secrets.outputs.slack_feedback_webhook_url }}" + SLACK_EDITS_WEBHOOK_URL: "${{ steps.app-secrets.outputs.slack_edits_webhook_url }}" OCOTILLO_UI_BASE_URL: "${{ vars.OCOTILLO_UI_BASE_URL || 'https://ocotillo-staging.newmexicowaterdata.org' }}" run: | export MAX_INSTANCES="10" diff --git a/CLAUDE.md b/CLAUDE.md index 88802a2a0..30549235f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,6 +180,18 @@ dependency and FastAPI treats it as a query parameter. only — it grants no access and removes no dependency. Apply it only to routes that genuinely have none. +**`/ogcapi-internal` is gated outside `Depends()`.** It is a raw Starlette +Mount, so `core/internal_ogc_auth.py` gates it at the ASGI layer instead. It +accepts a bearer Authentik JWT carrying `OGCInternal`, **or** a static API key +presented as a bearer token, as the Basic password, or as `?token=`. Only the +key digests are stored, as `label:sha256hex` entries in `INTERNAL_OGC_API_KEYS` +— sourced in deployed environments from the Secret Manager secret +`internal-ogc-api-keys` at deploy time, so revoking a key needs a redeploy. +Never a GitHub secret. The static keys exist because +ArcGIS Pro cannot send a bearer token at all and neither desktop client can +refresh an Authentik token. Read **`docs/internal-ogc-desktop-gis.md`** before +changing the credential paths. + ### Database Configuration The application supports two database modes (configured via `DB_DRIVER` in `.env`): diff --git a/core/internal_ogc_auth.py b/core/internal_ogc_auth.py index 85f1ee3dd..3d3631982 100644 --- a/core/internal_ogc_auth.py +++ b/core/internal_ogc_auth.py @@ -26,42 +26,177 @@ Kept separate from core/permissions.py to avoid a circular import with core/pygeoapi.py. + +Three credential transports are accepted, because the desktop GIS clients +this mount exists for cannot all carry an Authentik bearer token: + + * ``Authorization: Bearer `` -- QGIS's OAuth2 authentication method, + scripts, and anything that can talk to Authentik directly. + * ``Authorization: Basic `` -- the only scheme ArcGIS + Pro's "Add OGC API connection" dialog supports with saved credentials + (Authentication > Server Authentication). Esri does not support + token-secured OGC service connections at all. + * ``?token=`` -- ArcGIS Pro's "Custom request parameters", which the + client re-appends to every request it issues. Also a workaround for the + QGIS regression where OGC API - Features requests dropped the + Authorization header (qgis/QGIS#60473). + +The Basic and query-parameter transports carry a *static API key* (see +`api_key_label`) rather than a JWT, since neither ArcGIS nor QGIS can refresh +an Authentik access token before it expires. A bearer JWT is still accepted +and still checked for INTERNAL_OGC_GROUP membership; an API key is a +pre-authorized stand-in for that same group. + +The query-parameter transport puts the secret in the request URL, which App +Engine's request log records. Prefer Basic where the client supports it, and +treat keys handed out for ArcGIS as log-exposed when rotating. """ +import base64 +import binascii +import hashlib +import hmac import json +import os +from urllib.parse import parse_qsl, urlencode from starlette.types import ASGIApp, Receive, Scope, Send from core import permissions from core.settings import settings +# Comma- or whitespace-separated `label:sha256hex` entries. The label is for +# operator bookkeeping (who holds this key) and never appears in a response. +API_KEYS_ENV = "INTERNAL_OGC_API_KEYS" + +# Query parameter carrying a credential. Stripped before the request reaches +# pygeoapi so it cannot trip pygeoapi's unknown-parameter handling or leak +# into a provider's filter parsing. +TOKEN_QUERY_PARAM = "token" + +# Sent on 401 so ArcGIS Pro and QGIS surface a credential prompt instead of a +# bare failure. +WWW_AUTHENTICATE = 'Basic realm="Ocotillo Internal OGC API", charset="UTF-8"' + + +def _configured_api_keys() -> dict[str, str]: + """Parse API_KEYS_ENV into {label: sha256hex}. + + Read fresh on every call for the same reason + permissions.authentication_disabled() is: an import-time snapshot diverges + from a value changed after import, and the two checks disagreeing is how + the earlier auth bugs in this codebase presented. + + Malformed entries are skipped rather than raising. A typo in one entry + must not take the whole mount down for every other key holder. + """ + raw = os.environ.get(API_KEYS_ENV) or "" + keys: dict[str, str] = {} + for entry in raw.replace(",", " ").split(): + label, sep, digest = entry.partition(":") + digest = digest.strip().lower() + if not sep or not label.strip() or len(digest) != 64: + continue + try: + int(digest, 16) + except ValueError: + continue + keys[label.strip()] = digest + return keys + + +def api_key_label(secret: str) -> str | None: + """Return the configured label for `secret`, or None if it matches none. + + Compared as SHA-256 hex with hmac.compare_digest so neither the stored + material nor the comparison timing reveals a valid key. + """ + configured = _configured_api_keys() + if not configured: + return None + presented = hashlib.sha256(secret.encode("utf-8")).hexdigest() + for label, expected in configured.items(): + if hmac.compare_digest(presented, expected): + return label + return None + -def _extract_bearer_token(scope: Scope) -> str | None: +def _extract_credential(scope: Scope) -> str | None: + """Pull a credential out of the Authorization header or ?token=. + + Header wins over query parameter, and within the header both Bearer and + Basic are accepted. For Basic, the password half carries the secret + (username ignored, conventionally "apikey"); a Basic credential with an + empty password falls back to the username so pasting a key into either + field of a connection dialog works. + """ headers = dict(scope.get("headers") or []) authorization = headers.get(b"authorization") - if not authorization: + if authorization: + scheme, _, param = authorization.decode("latin-1").partition(" ") + scheme = scheme.lower() + param = param.strip() + if scheme == "bearer" and param: + return param + if scheme == "basic" and param: + try: + decoded = base64.b64decode(param, validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError, ValueError): + return None + username, sep, password = decoded.partition(":") + if not sep: + return None + return password or username or None return None - scheme, _, param = authorization.decode("latin-1").partition(" ") - if scheme.lower() != "bearer" or not param: - return None - return param + + for key, value in parse_qsl( + (scope.get("query_string") or b"").decode("latin-1"), keep_blank_values=True + ): + if key == TOKEN_QUERY_PARAM and value: + return value + return None -async def _send_json(send: Send, status_code: int, detail: str) -> None: +def _strip_token_query_param(scope: Scope) -> Scope: + """Return `scope` with any ?token= removed, copied only if it was present. + + pygeoapi echoes the incoming query string into the `self` and `next` links + it emits; leaving the secret in place would publish it in every response + body as well as in the request log. + """ + query_string = scope.get("query_string") or b"" + if TOKEN_QUERY_PARAM.encode("latin-1") not in query_string: + return scope + pairs = parse_qsl(query_string.decode("latin-1"), keep_blank_values=True) + remaining = [(k, v) for k, v in pairs if k != TOKEN_QUERY_PARAM] + if len(remaining) == len(pairs): + return scope + scope = dict(scope) + scope["query_string"] = urlencode(remaining).encode("latin-1") + return scope + + +async def _send_json( + send: Send, status_code: int, detail: str, *, challenge: bool = False +) -> None: body = json.dumps({"detail": detail}).encode("utf-8") + headers = [(b"content-type", b"application/json")] + if challenge: + headers.append((b"www-authenticate", WWW_AUTHENTICATE.encode("latin-1"))) await send( { "type": "http.response.start", "status": status_code, - "headers": [(b"content-type", b"application/json")], + "headers": headers, } ) await send({"type": "http.response.body", "body": body}) class InternalOGCAuthMiddleware: - """Gates every request under `mount_path` behind INTERNAL_OGC_GROUP - membership; requests to any other path pass straight through untouched. + """Gates every request under `mount_path` behind an API key or + INTERNAL_OGC_GROUP membership; requests to any other path pass straight + through untouched. Registered via app.add_middleware(), which wraps the whole app -- the path check below is what keeps this scoped to the internal mount only. @@ -71,8 +206,13 @@ def __init__(self, app: ASGIApp, mount_path: str) -> None: self.app = app self.mount_path = mount_path + def _covers(self, path: str) -> bool: + # Segment-boundary match, not a bare startswith: with mount_path + # "/ogcapi" a plain prefix test would also swallow "/ogcapi-internal". + return path == self.mount_path or path.startswith(f"{self.mount_path}/") + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope["type"] != "http" or not scope["path"].startswith(self.mount_path): + if scope["type"] != "http" or not self._covers(scope["path"]): await self.app(scope, receive, send) return @@ -87,25 +227,29 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: send, 424, permissions.bypass_misconfiguration_detail() ) return - await self.app(scope, receive, send) + await self.app(_strip_token_query_param(scope), receive, send) return - token = _extract_bearer_token(scope) - if not token: - await _send_json(send, 401, "Unauthorized") + secret = _extract_credential(scope) + if not secret: + await _send_json(send, 401, "Unauthorized", challenge=True) return - try: - payload = permissions.decode_token_payload(token) - except permissions.TokenInvalid: - await _send_json(send, 401, "Could not validate credentials") - return + if api_key_label(secret) is None: + # Not a static key, so it has to be an Authentik access token. + try: + payload = permissions.decode_token_payload(secret) + except permissions.TokenInvalid: + await _send_json( + send, 401, "Could not validate credentials", challenge=True + ) + return - if permissions.INTERNAL_OGC_GROUP not in payload.get("groups", []): - await _send_json(send, 403, "Forbidden") - return + if permissions.INTERNAL_OGC_GROUP not in payload.get("groups", []): + await _send_json(send, 403, "Forbidden") + return - await self.app(scope, receive, send) + await self.app(_strip_token_query_param(scope), receive, send) # ============= EOF ============================================= diff --git a/core/pygeoapi.py b/core/pygeoapi.py index 377d77c0f..017af4588 100644 --- a/core/pygeoapi.py +++ b/core/pygeoapi.py @@ -196,7 +196,16 @@ def _internal_server_url() -> str: configured = os.environ.get("PYGEOAPI_INTERNAL_SERVER_URL") if configured: return configured.rstrip("/") - return f"http://localhost:8000{_internal_mount_path()}" + # Derived from the application root rather than hardcoded to localhost. + # PYGEOAPI_INTERNAL_SERVER_URL is set in no deploy config -- not + # app.template.yaml, not any of the three CD workflows -- so every + # deployed environment fell into this branch and pygeoapi stamped + # "http://localhost:8000/ogcapi-internal" into the `self` and `next` links + # of every collection and items response. QGIS and ArcGIS Pro follow those + # links to page, so both walked off to localhost after the first page. + # _app_base_url() reads PYGEOAPI_SERVER_URL, which every deploy already + # sets, and still resolves to http://localhost:8000 for local development. + return f"{_app_base_url()}{_internal_mount_path()}" def _app_base_url() -> str: diff --git a/docs/internal-ogc-desktop-gis.md b/docs/internal-ogc-desktop-gis.md new file mode 100644 index 000000000..7efa66403 --- /dev/null +++ b/docs/internal-ogc-desktop-gis.md @@ -0,0 +1,139 @@ +# Connecting ArcGIS Pro and QGIS to `/ogcapi-internal` + +The internal OGC API mount serves the unfiltered (private- and draft-inclusive) +collections. It is gated by `core/internal_ogc_auth.py`, an ASGI middleware that +runs in front of the raw Starlette Mount — FastAPI's `Depends()` machinery never +sees these requests, so none of the `*_dependency` role parameters apply here. + +## Why there are static API keys at all + +The mount originally accepted only `Authorization: Bearer `. +Neither desktop client can sustain that: + +- **ArcGIS Pro** cannot send a bearer token to an OGC API connection. Its + connection dialog offers Basic ("Server Authentication"), Esri-portal OAuth, + and "Custom request parameters" (appended to the request URL). Esri + [does not support token-secured OGC service connections](https://pro.arcgis.com/en/pro-app/latest/help/data/services/add-ogc-api-services.htm). +- **QGIS** can send one via its OAuth2 or API Header authentication methods, but + shipped a regression where OGC API - Features requests dropped the + Authorization header entirely ([qgis/QGIS#60473](https://github.com/qgis/QGIS/issues/60473)). + +Neither client can refresh an Authentik access token before it expires, so even +a working bearer flow means re-pasting a token every hour. A static key issued +per user solves both problems. + +## Accepted credentials + +| Transport | Carries | Used by | +| --- | --- | --- | +| `Authorization: Bearer ` | Authentik JWT **or** API key | QGIS OAuth2 / API Header, scripts | +| `Authorization: Basic ` | API key (or JWT) as the password | ArcGIS Pro, QGIS Basic | +| `?token=` | API key (or JWT) | ArcGIS Pro custom request parameters | + +A JWT must additionally carry the `OGCInternal` group (`INTERNAL_OGC_GROUP` in +`core/permissions.py`); a valid JWT without it gets 403. An API key is a +pre-authorized stand-in for that group and carries no per-user claims. + +The `?token=` value is stripped from the query string before the request reaches +pygeoapi, so it never lands in the `self`/`next` links pygeoapi echoes into +response bodies. It is still recorded in App Engine's request log — prefer Basic +where the client supports it. + +## Where the keys live + +Only the **SHA-256 digests** are stored, never the keys themselves. The digest +list lives in a Google Secret Manager secret named `internal-ogc-api-keys`, one +per GCP project (production, staging, testing) — the same place the Jira and +Slack credentials live, not a GitHub secret. + +CD reads it at deploy time (`Fetch application secrets from Secret Manager` in +each `.github/workflows/CD_*.yml`) and `envsubst` renders it into `app.yaml` as +the `INTERNAL_OGC_API_KEYS` environment variable, which +`core/internal_ogc_auth.py` parses. The app makes no Secret Manager call at +runtime. + +Consequences worth knowing: + +- **The secret must exist before the next deploy of any environment.** + `get-secretmanager-secrets` fails the whole job on a missing secret. Seed each + project with a placeholder that parses to zero keys: + + ```bash + printf 'placeholder:none' | gcloud secrets create internal-ogc-api-keys --data-file=- --project + ``` + + The parser skips any entry whose digest is not 64 hex characters, so that + value is inert and means "bearer-JWT access only". + +- **Revoking a key requires a redeploy.** Adding a secret version does not + affect a running instance. If revocation ever needs to be immediate, that is + the point to switch to a runtime fetch with a TTL cache (same shape as the + JWKS cache in `core/permissions.py`) or to a keys table in Postgres. + +- The deploy service account needs `roles/secretmanager.secretAccessor` on + `internal-ogc-api-keys` in each project, alongside the four it already has. + +## Issuing a key + +```bash +python -c "import secrets,hashlib;k=secrets.token_urlsafe(32);print('key: ',k);print('digest:',hashlib.sha256(k.encode()).hexdigest())" +``` + +Give the **key** to the user over a secure channel and keep only the digest. +Append `