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
62 changes: 62 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Common commands

Install for development (Python >= 3.10; CI uses 3.12):

```bash
pip install --upgrade pip setuptools
pip install .[dev]
```

Run the example app (binds Flask to 127.0.0.1:5000):

```bash
cd example && python app.py
```

Tests, lint, format (commands match what CI runs in `.github/workflows/github-python-workflow.yml`):

```bash
pytest # all tests; testpaths=["ogc"]
pytest ogc/test/test_core.py # one file
pytest ogc/test/test_core.py::test_function_name # one test
pytest -k "substring" # filter by name

flake8 . --ignore=E,W,D,I,N806,N815,N818,Q000,Q001,Q002,S001,B008,B028 --max-line-length=120
black --check --diff -l 120 ogc example # check
black -l 120 ogc example # apply
```

Coverage (mirrors CI):

```bash
coverage run --data-file=coverage.bin --branch -m pytest --continue-on-collection-errors
coverage xml --data-file=coverage.bin -o coverage.xml
```

## Architecture

This package is a Python OGC server library (WCS 1.0.0, WMS 1.3.0, WMTS 1.0.0, EDR 1.1.0). It cleanly separates three layers:

1. **Protocol layer** — `ogc/wcs_request_1_0_0.py`, `wcs_response_1_0_0.py`, `wms_request_1_3_0.py`, `wms_response_1_3_0.py`, `wmts/wmts_request_1_0_0.py`, `wmts/wmts_response_1_0_0.py`. Each `*_request_*.py` parses/validates incoming KV (or XML) args; each `*_response_*.py` builds capability/coverage XML. They are pure Python — no web framework. The shared base `XMLNode` and `WCSException`/`WMTSException` live in `ogc/ogc_common.py`.

2. **Service core** — `ogc/core.py` exposes `OGC`, the framework-agnostic dispatcher. Its `handle_wcs_kv`, `handle_wms_kv`, `handle_wmts_kv` methods take a dict of (lowercased) request args and return either a string (XML) or a dict `{"fp": <BytesIO>, "fn": <filename>}` for binary responses. EDR is handled separately via `ogc/edr/EdrRoutes` (built on `pygeoapi`); WMTS via `ogc/wmts/WmtsRoutes`. Each subsystem is constructed only if the corresponding flag in `ogc/settings.py` is enabled.

3. **Web framework adapters** — `ogc/servers.py` wraps `OGC` for Flask (`FlaskServer`). It is the *only* place that knows about Flask. `FastAPI` is stubbed (`NotImplementedError`). When adding a new framework, model it after `FlaskServer`: convert framework request → arg dict, call `ogc.handle_*_kv`, convert return value back to a framework response. `_check_query_string` and the regex-based arg sanitizer in `ogc_render`/`edr_render` enforce input limits (`MAX_QUERY_STRING_BYTES`) and allowlist characters — keep these guards in any new adapter.

**Data source plug-ins** — `ogc/Layer` (in `ogc/__init__.py`) is an abstract `traitlets.HasTraits` interface (`get_map`, `get_coverage`, `get_legend_graphic`, plus a `GridCoordinates` footprint). `ogc/podpac.py` provides the concrete PODPAC-backed implementation (`podpac.Layer` subclasses `ogc.Layer`). Other backends should subclass `ogc.Layer` similarly; do not add backend-specific code into `core.py`.

**Configuration via env vars** (`ogc/settings.py`): `OGC_SUPPORTED_FORMATS` (comma list of `wms,wcs,wmts,edr`) controls which subsystems light up at `OGC.__init__` time; `EDR_CONFIGURATION_PATH` and `FRONT_END_ADDRESS` are also read from env. Limits like `MAX_GRID_COORDS_REQUEST_SIZE` and `MAX_QUERY_STRING_BYTES` are enforced here.

**Exception flow** — anywhere inside `core.py` or layer implementations, raise `WCSException` (or `WMTSException` for WMTS paths) with a proper `exception_code` and `locator`. The Flask adapter renders these as 400 responses; any other exception becomes a generic 500 with a sanitized XML body. Do not let raw exceptions propagate out of `handle_*_kv` for security reasons (a 500 can be misread as evidence of injection success — see comments in `servers.py:ogc_render`).

## Conventions specific to this repo

- Black with `line-length = 120`. The pre-commit hook installs automatically via `setup.py develop`'s `PostDevelopCommand`.
- `traitlets.HasTraits` is used heavily for typed data classes throughout the protocol and core layers — follow that pattern instead of dataclasses or pydantic.
- Bare `except Exception` blocks that intentionally swallow/translate errors at the API boundary are tagged with `# noqa: B902` (flake8-blind-except). Do not remove these unless replacing with a specific exception type.
- `pytest.ini_options.testpaths = ["ogc"]` — tests live alongside code in `ogc/test/`, `ogc/wmts/test/`, `ogc/edr/test/`. Put new tests next to the module they exercise.
- Version is bumped manually in `ogc/version.py` (MAJOR/MINOR/HOTFIX constants); `version.version()` augments with `git describe` when in a checkout.
14 changes: 7 additions & 7 deletions ogc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def get_capabilities_wcs(self, args):
try:
get_capabilities.load_from_kv(args)
get_capabilities.validate()
except Exception:
except Exception: # noqa: B902
logger.error(LOAD_FAILURE, exc_info=True)
raise WCSException(exception_text=INVALID_ARGUMENTS)

Expand All @@ -113,7 +113,7 @@ def describe_coverage_wcs(self, args, wcs_request, wcs_response):
try:
describe_coverage.load_from_kv(args)
describe_coverage.validate()
except Exception:
except Exception: # noqa: B902
logger.error(LOAD_FAILURE, exc_info=True)
raise WCSException(exception_text=INVALID_ARGUMENTS)

Expand All @@ -127,7 +127,7 @@ def get_coverage_wcs(self, args, wcs_request):
try:
get_coverage.load_from_kv(args)
get_coverage.validate()
except Exception:
except Exception: # noqa: B902
logger.error(LOAD_FAILURE, exc_info=True)
raise WCSException(exception_text=INVALID_ARGUMENTS)

Expand Down Expand Up @@ -191,7 +191,7 @@ def get_capabilities_wms(self, args):
try:
get_capabilities.load_from_kv(args)
get_capabilities.validate()
except Exception:
except Exception: # noqa: B902
logger.error(LOAD_FAILURE, exc_info=True)
raise WCSException(exception_text=INVALID_ARGUMENTS)

Expand All @@ -206,7 +206,7 @@ def get_legend_graphic(self, args, wms_request):
try:
get_legend_graphic.load_from_kv(args)
get_legend_graphic.validate()
except Exception:
except Exception: # noqa: B902
logger.error(LOAD_FAILURE, exc_info=True)
raise WCSException(exception_text=INVALID_ARGUMENTS)

Expand All @@ -224,7 +224,7 @@ def get_map(self, args, wms_request):
try:
get_map.load_from_kv(args)
get_map.validate()
except Exception:
except Exception: # noqa: B902
logger.error(LOAD_FAILURE, exc_info=True)
raise WCSException(exception_text=INVALID_ARGUMENTS)

Expand Down Expand Up @@ -253,7 +253,7 @@ def get_map(self, args, wms_request):

try:
fp = coverage.layer.get_map(args)
except Exception:
except Exception: # noqa: B902
logger.error("Failed to get_map from layer: ", exc_info=True)
raise WCSException(exception_text=INVALID_ARGUMENTS)

Expand Down
4 changes: 3 additions & 1 deletion ogc/edr/edr_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from http import HTTPStatus
from datetime import datetime, timezone
from typing import Tuple, List, Dict, Any, Union

from traitlets import TraitError
from ogc import podpac as pogc
from pygeoapi.plugin import load_plugin
from pygeoapi.util import filter_dict_by_key_value, to_json, get_provider_by_type
Expand Down Expand Up @@ -394,7 +396,7 @@ def _crs84_bounding_box(layer: pogc.Layer) -> Tuple[float, float, float, float]:
layer.grid_coordinates.URC.lon,
layer.grid_coordinates.URC.lat,
)
except Exception:
except (AttributeError, TraitError):
crs_extents = settings.EDR_CRS[settings.crs_84_uri_format]
return (crs_extents["minx"], crs_extents["miny"], crs_extents["maxx"], crs_extents["maxy"])

Expand Down
8 changes: 2 additions & 6 deletions ogc/edr/static/css/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ header {

main {
background-color: white;
padding-bottom: 65px;
/* prevent from falling under the footer */
}

.crumbs {
Expand All @@ -19,7 +21,6 @@ main {
padding: 0px 6px;
color: black;
text-decoration: none;
/* text-transform: capitalize;*/
}

#items-map,
Expand Down Expand Up @@ -60,11 +61,6 @@ footer.sticky-bottom {
margin-top: auto;
}

main {
padding-bottom: 65px;
/* prevent from falling under the footer */
}

table:not(.horizontal) {
max-height: none;
}
Expand Down
4 changes: 2 additions & 2 deletions ogc/servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ def ogc_render(self, ogc_idx):
# Security scans have flagged a security concern when returning a 500 error,
# since it might imply successful command injection.
return respond_xml(e.to_xml(), status=400)
except Exception as e:
except Exception as e: # noqa: B902
logger.error("OGC: server.ogc_render Exception: %s", str(e), exc_info=True)
ee = WCSException()
return respond_xml(ee.to_xml(), status=500)
Expand Down Expand Up @@ -391,7 +391,7 @@ def wrapper(*args, **kwargs) -> Response:
except WCSException as e:
logger.error("OGC: server.edr_render WCSException: %s", str(e), exc_info=True)
return respond_xml(e.to_xml(), status=400)
except Exception as e:
except Exception as e: # noqa: B902
logger.error("OGC: server.edr_render Exception: %s", str(e), exc_info=True)
ee = WCSException()
return respond_xml(ee.to_xml(), status=500)
Expand Down
10 changes: 4 additions & 6 deletions ogc/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@

# WMS Capabilities limit layers
WMS_LIMIT_LAYERS = False
WMS_LAYERS = []

# get front end web address if set
try:
Expand All @@ -86,19 +87,16 @@
else:
WMS_FRONT_END_ADDRESS = FRONT_END_ADDRESS + "/services/GEOWCS"
WCS_FRONT_END_ADDRESS = FRONT_END_ADDRESS + "/services/GEOWCS"
except Exception:
except KeyError:
WMS_FRONT_END_ADDRESS = None
WCS_FRONT_END_ADDRESS = None

CLASSIFICATION = "NONE" # not used any more seemingly
PUBLIC_CONSTRAINT_STRING = "PUBLIC"
CONSTRAINTS = PUBLIC_CONSTRAINT_STRING

# get EDR configuration file path
try:
EDR_CONFIGURATION_PATH = os.environ["EDR_CONFIGURATION_PATH"]
except Exception:
EDR_CONFIGURATION_PATH = None
# get EDR configuration file path (will be None if unspecified)
EDR_CONFIGURATION_PATH = os.environ.get("EDR_CONFIGURATION_PATH")

# get supported formats
OGC_SUPPORTED_FORMATS = os.environ.get("OGC_SUPPORTED_FORMATS", "wms,wcs")
Expand Down
108 changes: 108 additions & 0 deletions ogc/test/test_servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from ogc import core
from ogc import podpac as pogc
from ogc import settings
from ogc.ogc_common import WCSException
from pygeoapi.api import APIRequest
from unittest.mock import patch
from typing import Callable, Generator

Expand Down Expand Up @@ -182,3 +184,109 @@ def test_server_without_wms_supported_service(supported_formats, client):

response = client.get("/ogc/edr")
assert response.status_code == 404


# ---------------------------------------------------------------------------
# edr_render tests
# ---------------------------------------------------------------------------


@pytest.fixture
def enable_edr_in_env():
"""Test client for FlaskServer with EDR enabled."""
with patch.dict("os.environ", {"OGC_SUPPORTED_FORMATS": "edr"}):
importlib.reload(settings)
yield
importlib.reload(settings)


def test_edr_render_get_landing_page(enable_edr_in_env, client):
response = client.get("/ogc/edr?f=json")
assert response.status_code == 200


def test_edr_render_get_conformance(enable_edr_in_env, client):
response = client.get("/ogc/edr/conformance?f=json")
assert response.status_code == 200


def test_edr_render_get_collections(enable_edr_in_env, client):
response = client.get("/ogc/edr/collections?f=json")
assert response.status_code == 200


def test_edr_render_post_returns_405(enable_edr_in_env, client):
response = client.post("/ogc/edr")
assert response.status_code == 405


def test_edr_render_query_string_too_long_returns_400(enable_edr_in_env, client):
oversized = "f=json&" + "A=" + "B" * settings.MAX_QUERY_STRING_BYTES
response = client.get("/ogc/edr", environ_overrides={"QUERY_STRING": oversized})
assert response.status_code == 400


def test_edr_render_query_string_invalid_utf8_returns_400(enable_edr_in_env, client):
response = client.get("/ogc/edr", environ_overrides={"QUERY_STRING": "f=json&bad=\xff\xfe"})
assert response.status_code == 400


def test_edr_render_disallowed_chars_are_stripped(enable_edr_in_env, client):
"""Characters outside the allowlist are removed from query values before the handler is called."""
captured_args = {}
original_from_flask = APIRequest.from_flask

def capturing(request, locales):
captured_args.update(request.args)
return original_from_flask(request, locales)

with patch.object(APIRequest, "from_flask", new=capturing):
response = client.get("/ogc/edr", environ_overrides={"QUERY_STRING": "f=json&foo=bar!@#$"})

assert response.status_code == 200
assert all(c not in captured_args.get("foo", "") for c in "!@#$")


def test_edr_render_format_param_is_lowercased(enable_edr_in_env, client):
"""The f= query parameter is normalized to lowercase before the handler receives it."""
captured_args = {}
original_from_flask = APIRequest.from_flask

def capturing(request, locales):
captured_args.update(request.args)
return original_from_flask(request, locales)

with patch.object(APIRequest, "from_flask", new=capturing):
client.get("/ogc/edr?f=JSON")

assert captured_args.get("f") == "json"


def test_edr_render_wcs_exception_returns_400(enable_edr_in_env, client):
"""WCSException raised by a handler is returned as a 400 XML response."""
app = client.application

def raises_wcs_exception(api_request, *args, **kwargs):
raise WCSException("test error")

wrapper = app.edr_render(raises_wcs_exception)
app.add_url_rule("/test_edr_wcs", endpoint="test_edr_wcs", view_func=wrapper, methods=["GET"])

response = client.get("/test_edr_wcs")
assert response.status_code == 400
assert "ExceptionReport" in response.get_data(as_text=True)


def test_edr_render_exception_returns_500(enable_edr_in_env, client):
"""Unexpected exceptions in a handler are returned as a 500 XML response."""
app = client.application

def raises_runtime_error(api_request, *args, **kwargs):
raise RuntimeError("unexpected")

wrapper = app.edr_render(raises_runtime_error)
app.add_url_rule("/test_edr_exc", endpoint="test_edr_exc", view_func=wrapper, methods=["GET"])

response = client.get("/test_edr_exc")
assert response.status_code == 500
assert "ExceptionReport" in response.get_data(as_text=True)
6 changes: 3 additions & 3 deletions ogc/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,17 @@ def version():
git = "git"
try:
subprocess.check_output([git, "--version"])
except Exception:
except (subprocess.CalledProcessError, OSError):
git = "/usr/bin/git"
try:
subprocess.check_output([git, "--version"])
except Exception:
except (subprocess.CalledProcessError, OSError):
return version_full

version_full = subprocess.check_output([git, "describe", "--always"], cwd=CWD).strip().decode("ascii")
version_full = version_full.replace("-", "+", 1).replace("-", ".") # Make this consistent with PEP440

except Exception as e:
except (subprocess.CalledProcessError, OSError) as e:
print("Could not determine Project version from git repo.\n" + str(e))

return version_full
Loading
Loading