From a242b36ed9058d82b0ed9f005cea5c92e3d00f50 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 25 Mar 2026 15:25:46 -0600 Subject: [PATCH 1/3] feat: implement dynamic loading of pygeoapi app and improve description formatting --- core/pygeoapi.py | 57 ++++++++++++++++++++++++++---------- tests/test_pygeoapi_mount.py | 50 +++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 tests/test_pygeoapi_mount.py diff --git a/core/pygeoapi.py b/core/pygeoapi.py index 0ac68b1b..7783af10 100644 --- a/core/pygeoapi.py +++ b/core/pygeoapi.py @@ -1,5 +1,7 @@ +import importlib import os import re +import sys import textwrap from importlib.util import find_spec from pathlib import Path @@ -12,28 +14,38 @@ "id": "water_wells", "title": "Water Wells", "thing_type": "water well", - "description": "Groundwater wells used for monitoring, production, and hydrogeologic investigations.", + "description": ( + "Groundwater wells used for monitoring, production, and " + "hydrogeologic investigations." + ), "keywords": ["well", "groundwater", "water-well"], }, { "id": "springs", "title": "Springs", "thing_type": "spring", - "description": "Natural spring features and associated spring monitoring points.", + "description": ( + "Natural spring features and associated spring monitoring points." + ), "keywords": ["springs", "groundwater-discharge"], }, { "id": "diversions_surface_water", "title": "Surface Water Diversions", "thing_type": "diversion of surface water, etc.", - "description": "Diversion structures such as ditches, canals, and intake points.", + "description": ( + "Diversion structures such as ditches, canals, and intake points." + ), "keywords": ["surface-water", "diversion"], }, { "id": "ephemeral_streams", "title": "Ephemeral Streams", "thing_type": "ephemeral stream", - "description": "Stream reaches that flow only in direct response to precipitation events.", + "description": ( + "Stream reaches that flow only in direct response to " + "precipitation events." + ), "keywords": ["ephemeral-stream", "surface-water"], }, { @@ -54,7 +66,9 @@ "id": "other_things", "title": "Other Thing Types", "thing_type": "other", - "description": "Feature records that do not match another defined thing type.", + "description": ( + "Feature records that do not match another defined thing type." + ), "keywords": ["other"], }, { @@ -68,21 +82,23 @@ "id": "perennial_streams", "title": "Perennial Streams", "thing_type": "perennial stream", - "description": "Stream reaches with continuous or near-continuous flow.", + "description": ("Stream reaches with continuous or near-continuous flow."), "keywords": ["perennial-stream", "surface-water"], }, { "id": "rock_sample_locations", "title": "Rock Sample Locations", "thing_type": "rock sample location", - "description": "Locations where rock samples were collected or documented.", + "description": ("Locations where rock samples were collected or documented."), "keywords": ["rock-sample"], }, { "id": "soil_gas_sample_locations", "title": "Soil Gas Sample Locations", "thing_type": "soil gas sample location", - "description": "Locations where soil gas measurements or samples were collected.", + "description": ( + "Locations where soil gas measurements or samples were collected." + ), "keywords": ["soil-gas", "sample-location"], }, ] @@ -104,7 +120,8 @@ def _mount_path() -> str: if not path.startswith("/"): path = f"/{path}" - # Remove any trailing slashes so "/ogcapi/" and "ogcapi/" both become "/ogcapi". + # Remove trailing slashes so "/ogcapi/" and "ogcapi/" both become + # "/ogcapi". path = path.rstrip("/") # Disallow traversal/current-directory segments. @@ -114,7 +131,8 @@ def _mount_path() -> str: "Invalid PYGEOAPI_MOUNT_PATH: traversal segments are not allowed." ) - # Allow only slash-delimited segments of alphanumerics, underscore, or hyphen. + # Allow only slash-delimited segments of alphanumerics, underscore, + # or hyphen. if not re.fullmatch(r"/[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*", path): raise ValueError( "Invalid PYGEOAPI_MOUNT_PATH: only letters, numbers, underscores, " @@ -208,8 +226,8 @@ def _pygeoapi_db_settings() -> tuple[str, str, str, str, str]: ).strip() if not user: raise RuntimeError( - "PYGEOAPI_POSTGRES_USER or POSTGRES_USER must be set and non-empty " - "to generate the pygeoapi configuration." + "PYGEOAPI_POSTGRES_USER or POSTGRES_USER must be set and " + "non-empty to generate the pygeoapi configuration." ) if os.environ.get("PYGEOAPI_POSTGRES_PASSWORD") is None: raise RuntimeError( @@ -261,12 +279,22 @@ def _generate_openapi(config_path: Path, openapi_path: Path) -> None: openapi_path.write_text(openapi, encoding="utf-8") +def _load_pygeoapi_app(): + module_name = "pygeoapi.starlette_app" + if module_name in sys.modules: + module = importlib.reload(sys.modules[module_name]) + else: + module = importlib.import_module(module_name) + return module.APP + + def mount_pygeoapi(app: FastAPI) -> None: if getattr(app.state, "pygeoapi_mounted", False): return if find_spec("pygeoapi") is None: raise RuntimeError( - "pygeoapi is not installed. Rebuild/sync dependencies so /ogcapi can be mounted." + "pygeoapi is not installed. Rebuild/sync dependencies so " + "/ogcapi can be mounted." ) pygeoapi_dir = _pygeoapi_dir() @@ -278,8 +306,7 @@ def mount_pygeoapi(app: FastAPI) -> None: os.environ["PYGEOAPI_CONFIG"] = str(config_path) os.environ["PYGEOAPI_OPENAPI"] = str(openapi_path) - from pygeoapi.starlette_app import APP as pygeoapi_app - + pygeoapi_app = _load_pygeoapi_app() mount_path = _mount_path() app.mount(mount_path, pygeoapi_app) diff --git a/tests/test_pygeoapi_mount.py b/tests/test_pygeoapi_mount.py new file mode 100644 index 00000000..c789dc30 --- /dev/null +++ b/tests/test_pygeoapi_mount.py @@ -0,0 +1,50 @@ +import types + +from core import pygeoapi + + +def test_load_pygeoapi_app_imports_when_module_not_loaded(monkeypatch): + fake_module = types.SimpleNamespace(APP=object()) + import_calls = [] + + def fake_import_module(name): + import_calls.append(name) + return fake_module + + monkeypatch.delitem( + pygeoapi.sys.modules, + "pygeoapi.starlette_app", + raising=False, + ) + monkeypatch.setattr( + pygeoapi.importlib, + "import_module", + fake_import_module, + ) + + app = pygeoapi._load_pygeoapi_app() + + assert app is fake_module.APP + assert import_calls == ["pygeoapi.starlette_app"] + + +def test_load_pygeoapi_app_reloads_when_module_already_loaded(monkeypatch): + existing_module = types.SimpleNamespace(APP=object()) + reloaded_module = types.SimpleNamespace(APP=object()) + reload_calls = [] + + def fake_reload(module): + reload_calls.append(module) + return reloaded_module + + monkeypatch.setitem( + pygeoapi.sys.modules, + "pygeoapi.starlette_app", + existing_module, + ) + monkeypatch.setattr(pygeoapi.importlib, "reload", fake_reload) + + app = pygeoapi._load_pygeoapi_app() + + assert app is reloaded_module.APP + assert reload_calls == [existing_module] From 2dd6628dacd34f1a214b49982aea6264ed90a639 Mon Sep 17 00:00:00 2001 From: jross Date: Wed, 25 Mar 2026 15:34:04 -0600 Subject: [PATCH 2/3] fix: add unmatched locations to the import process and update test assertions --- cli/project_area_import.py | 1 + tests/test_cli_commands.py | 59 ++++++++++++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/cli/project_area_import.py b/cli/project_area_import.py index ade08a86..a16d4147 100644 --- a/cli/project_area_import.py +++ b/cli/project_area_import.py @@ -119,6 +119,7 @@ def import_project_area_boundaries( ) if not groups: + unmatched_locations.append(location_name) new_group = Group( name=location_name, group_type=group_type, diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 6db32370..375c77df 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -68,7 +68,9 @@ def __exit__(self, exc_type, exc, tb): assert "Refreshed 7 materialized view(s)." in result.output -def test_refresh_pygeoapi_materialized_views_custom_and_concurrently(monkeypatch): +def test_refresh_pygeoapi_materialized_views_custom_and_concurrently( + monkeypatch, +): executed_sql: list[str] = [] execution_options: list[dict[str, object]] = [] @@ -190,6 +192,7 @@ class FakeSession: def __init__(self): self.commit_called = False self.scalar_calls = 0 + self.added = [] def scalars(self, stmt): self.scalar_calls += 1 @@ -197,6 +200,9 @@ def scalars(self, stmt): return FakeScalarResult([fake_group]) return FakeScalarResult([]) + def add(self, obj): + self.added.append(obj) + def commit(self): self.commit_called = True @@ -208,15 +214,20 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): return False - monkeypatch.setattr("cli.project_area_import.session_ctx", lambda: FakeSessionCtx()) + monkeypatch.setattr( + "cli.project_area_import.session_ctx", + lambda: FakeSessionCtx(), + ) runner = CliRunner() result = runner.invoke(cli, ["import-project-area-boundaries"]) assert result.exit_code == 0, result.output assert "Fetched 2 feature(s)." in result.output - assert "Matched 1 group row(s)." in result.output + assert "Matched 2 group row(s)." in result.output + assert "Created 1 group(s)." in result.output assert "Updated 1 group project area(s)." in result.output + assert "Skipped 0 unchanged group(s)." in result.output assert "Unmatched locations: Missing Group" in result.output assert fake_group.project_area is not None @@ -408,7 +419,10 @@ def fake_run(command, check, env, capture_output, text): return SimpleNamespace(returncode=0) monkeypatch.setattr("cli.db_restore._reset_target_schema", lambda: None) - monkeypatch.setattr("cli.db_restore.get_storage_bucket", fake_get_storage_bucket) + monkeypatch.setattr( + "cli.db_restore.get_storage_bucket", + fake_get_storage_bucket, + ) monkeypatch.setattr("cli.db_restore.subprocess.run", fake_run) monkeypatch.setenv("POSTGRES_HOST", "localhost") monkeypatch.setenv("POSTGRES_DB", "ocotilloapi_dev") @@ -471,7 +485,10 @@ def fake_well_inventory(file_path): }, ) - monkeypatch.setattr("cli.service_adapter.well_inventory_csv", fake_well_inventory) + monkeypatch.setattr( + "cli.service_adapter.well_inventory_csv", + fake_well_inventory, + ) runner = CliRunner() result = runner.invoke(cli, ["well-inventory-csv", str(inventory_file)]) @@ -500,7 +517,8 @@ def write_summary(path, comparison): captured["result_count"] = len(comparison.results) monkeypatch.setattr( - "transfers.transfer_results_builder.TransferResultsBuilder", FakeBuilder + "transfers.transfer_results_builder.TransferResultsBuilder", + FakeBuilder, ) summary_path = tmp_path / "metrics" / "summary.md" @@ -558,7 +576,10 @@ def fake_well_inventory(_file_path): }, ) - monkeypatch.setattr("cli.service_adapter.well_inventory_csv", fake_well_inventory) + monkeypatch.setattr( + "cli.service_adapter.well_inventory_csv", + fake_well_inventory, + ) runner = CliRunner() result = runner.invoke(cli, ["well-inventory-csv", str(inventory_file)]) @@ -624,14 +645,28 @@ def fake_upload(file_path, *, pretty_json=False): def test_water_levels_cli_persists_observations(tmp_path, water_well_thing): """ - End-to-end CLI invocation should create FieldEvent, Sample, and Observation rows. + End-to-end CLI invocation should create FieldEvent, Sample, + and Observation rows. """ def _write_csv(path: Path, *, well_name: str, notes: str): - csv_text = textwrap.dedent(f"""\ - field_staff,well_name_point_id,field_event_date_time,measurement_date_time,sampler,sample_method,mp_height,level_status,depth_to_water_ft,data_quality,water_level_notes - CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes} - """) + header = ( + "field_staff,well_name_point_id,field_event_date_time," + "measurement_date_time,sampler,sample_method,mp_height," + "level_status,depth_to_water_ft,data_quality," + "water_level_notes" + ) + row = ( + f"CLI Tester,{well_name},2025-02-15T08:00:00-07:00," + "2025-02-15T10:30:00-07:00,Groundwater Team,electric tape," + f"1.5,stable,42.5,approved,{notes}" + ) + csv_text = textwrap.dedent( + f"""\ + {header} + {row} + """ + ) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}" From 61fedba44fe5512912a2bd8539ec1042520db192 Mon Sep 17 00:00:00 2001 From: jirhiker <2035568+jirhiker@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:34:27 +0000 Subject: [PATCH 3/3] 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 375c77df..fb351fbd 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -661,12 +661,10 @@ def _write_csv(path: Path, *, well_name: str, notes: str): "2025-02-15T10:30:00-07:00,Groundwater Team,electric tape," f"1.5,stable,42.5,approved,{notes}" ) - csv_text = textwrap.dedent( - f"""\ + csv_text = textwrap.dedent(f"""\ {header} {row} - """ - ) + """) path.write_text(csv_text) unique_notes = f"pytest-{uuid.uuid4()}"