diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6797234 --- /dev/null +++ b/CLAUDE.md @@ -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": , "fn": }` 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. diff --git a/ogc/core.py b/ogc/core.py index d214d2f..043d90d 100755 --- a/ogc/core.py +++ b/ogc/core.py @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/ogc/edr/edr_api.py b/ogc/edr/edr_api.py index 7758e1e..902e65c 100644 --- a/ogc/edr/edr_api.py +++ b/ogc/edr/edr_api.py @@ -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 @@ -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"]) diff --git a/ogc/edr/static/css/default.css b/ogc/edr/static/css/default.css index 36f9261..5a792d7 100644 --- a/ogc/edr/static/css/default.css +++ b/ogc/edr/static/css/default.css @@ -8,6 +8,8 @@ header { main { background-color: white; + padding-bottom: 65px; + /* prevent from falling under the footer */ } .crumbs { @@ -19,7 +21,6 @@ main { padding: 0px 6px; color: black; text-decoration: none; - /* text-transform: capitalize;*/ } #items-map, @@ -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; } diff --git a/ogc/servers.py b/ogc/servers.py index 64dcc77..dbd445d 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -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) @@ -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) diff --git a/ogc/settings.py b/ogc/settings.py index ee516a6..f2f324c 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -76,6 +76,7 @@ # WMS Capabilities limit layers WMS_LIMIT_LAYERS = False +WMS_LAYERS = [] # get front end web address if set try: @@ -86,7 +87,7 @@ 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 @@ -94,11 +95,8 @@ 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") diff --git a/ogc/test/test_servers.py b/ogc/test/test_servers.py index a58e6fb..10871cb 100644 --- a/ogc/test/test_servers.py +++ b/ogc/test/test_servers.py @@ -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 @@ -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) diff --git a/ogc/version.py b/ogc/version.py index 8509ca4..72012e9 100755 --- a/ogc/version.py +++ b/ogc/version.py @@ -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 diff --git a/ogc/wcs_response_1_0_0.py b/ogc/wcs_response_1_0_0.py index 0b657db..d25017a 100755 --- a/ogc/wcs_response_1_0_0.py +++ b/ogc/wcs_response_1_0_0.py @@ -69,7 +69,7 @@ def _wgs84_bounding_box_lower_corner_lat_lon_default(self): self.layer.grid_coordinates.LLC.lat, self.layer.grid_coordinates.LLC.lon, ) - except Exception: + except (AttributeError, tl.TraitError): return (self.crs_extents["minx"], self.crs_extents["miny"]) wgs84_bounding_box_upper_corner_lat_lon = tl.Tuple(tl.Float(), tl.Float()) @@ -80,7 +80,7 @@ def _wgs84_bounding_box_upper_corner_lat_lon_default(self): self.layer.grid_coordinates.URC.lat, self.layer.grid_coordinates.URC.lon, ) - except Exception: + except (AttributeError, tl.TraitError): return (self.crs_extents["maxx"], self.crs_extents["maxy"]) @@ -279,21 +279,12 @@ def capability(self): coverages = tl.List(tl.Instance(klass=Coverage)) # is populated via Traits in constructor - # Check if list of layers available should be trimmed - layer_subset = [] - limit_layers = False - try: - limit_layers = settings.WMS_LIMIT_LAYERS - layer_subset = settings.WMS_LAYERS - except Exception as e: - logger.info("Layer limiting settings not enabled: {}".format(e)) - def contents(self): xml = " \n" # If configured, trim layers list to layers specified in settings - if self.limit_layers: - self.coverages = [layer for layer in self.coverages if layer.identifier in self.layer_subset] + if settings.WMS_LIMIT_LAYERS: + self.coverages = [layer for layer in self.coverages if layer.identifier in settings.WMS_LAYERS] for coverage in self.coverages: xml += " \n" diff --git a/ogc/wms_response_1_3_0.py b/ogc/wms_response_1_3_0.py index 813a487..207c026 100755 --- a/ogc/wms_response_1_3_0.py +++ b/ogc/wms_response_1_3_0.py @@ -106,7 +106,7 @@ def exception(self): try: limit_layers = settings.WMS_LIMIT_LAYERS layer_subset = settings.WMS_LAYERS - except Exception as e: + except (AttributeError, tl.TraitError) as e: logger.info("Layer limiting settings not enabled: {}".format(e)) def coverage_times_list(self, coverage, default_time): diff --git a/ogc/wmts/wmts_routes.py b/ogc/wmts/wmts_routes.py index 15ca355..299d93a 100644 --- a/ogc/wmts/wmts_routes.py +++ b/ogc/wmts/wmts_routes.py @@ -180,7 +180,7 @@ def get_tile(self, args: Dict[str, Any]) -> Dict[str, Any]: try: fp = coverage.layer.get_map(map_args) - except Exception: + except Exception: # noqa: B902 logger.error("Failed to get_tile from layer: ", exc_info=True) raise WMTSException(exception_text=INVALID_ARGUMENTS) diff --git a/pyproject.toml b/pyproject.toml index e7d6cc5..91e2959 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ dev = [ "flake8", "flake8-bugbear", "flake8-builtins", + "flake8-blind-except", ] [tool.setuptools.packages.find]