Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/project_area_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 42 additions & 15 deletions core/pygeoapi.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import importlib
import os
import re
import sys
import textwrap
from importlib.util import find_spec
from pathlib import Path
Expand All @@ -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"],
},
{
Expand All @@ -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"],
},
{
Expand All @@ -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"],
},
]
Expand All @@ -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.
Expand All @@ -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, "
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand All @@ -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)

Expand Down
53 changes: 43 additions & 10 deletions tests/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = []

Expand Down Expand Up @@ -190,13 +192,17 @@ class FakeSession:
def __init__(self):
self.commit_called = False
self.scalar_calls = 0
self.added = []

def scalars(self, stmt):
self.scalar_calls += 1
if self.scalar_calls == 1:
return FakeScalarResult([fake_group])
return FakeScalarResult([])

def add(self, obj):
self.added.append(obj)

def commit(self):
self.commit_called = True

Expand All @@ -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

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)])
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)])
Expand Down Expand Up @@ -624,13 +645,25 @@ 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):
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"""\
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}
{row}
""")
path.write_text(csv_text)

Expand Down
50 changes: 50 additions & 0 deletions tests/test_pygeoapi_mount.py
Original file line number Diff line number Diff line change
@@ -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]
Loading