Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4a8c530
Implement WMTS endpoints
scranford1 May 4, 2026
5e0b1e5
Remove unneeded dpi constant for WMTS
scranford1 May 5, 2026
471c512
Remove duplication of code in WMTS response and request
scranford1 May 5, 2026
213505a
Make earth circumference a constant
scranford1 May 6, 2026
f8d758a
Fix abstraction for WMTS versioning
scranford1 May 6, 2026
c04cf25
Merge pull request #27 from creare-com/feature/wmts
jdw-creare May 7, 2026
1967107
Updated blind exceptions
jdw-creare May 4, 2026
de23fca
Added `noqa` for a place where we want to catch all exceptions, inclu…
jdw-creare May 9, 2026
7c2b86e
Addressed sonarqube findings
jdw-creare May 9, 2026
1959884
Ran /init in Claude Code
jdw-creare May 9, 2026
ce26a07
Add unit tests for FlaskServer.edr_render()
jdw-creare May 9, 2026
c54fdc5
Claude copy-pasted the `client` fixture from earlier in the file; edi…
jdw-creare May 11, 2026
13026d3
Simplified - "exceptions should be exceptional" principle. Here we c…
jdw-creare May 14, 2026
7acf79c
Merge pull request #28 from creare-com/feature/lint_blind_except
jdw-creare May 14, 2026
2741439
santizing error messages facing users
BAyotte May 15, 2026
8c46ae1
Merge pull request #29 from creare-com/feature/sanitize-errors
BAyotte May 15, 2026
3511904
Add support for hierarchical layers in WMS GetCapabilities (#30)
scranford1 Jun 5, 2026
37a545a
Release 0.6.0
scranford1 Jun 5, 2026
e3f6042
Replace logger error with logger exception in except blocks
scranford1 Jun 5, 2026
598fb04
Add python dependency lock file to source control
scranford1 Jun 5, 2026
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
# Changelog
## 0.6.0

### Introduction
Support for Web Map Tile Service (WMTS) was added in this version to extend OGC capabilities.
WMTS is optional and can be enabled via the OGC_SUPPORTED_FORMATS environmental variable.
The currently supported version of WMTS is v1.0.0. Additionally, optional hierarchical layering
was added within WMS GetCapabilities. Code quality and security was improved by reducing blind
exceptions and sanitizing error messages.

### Features
* Added support for WMTS v1.0.0.
* Added support for hierarchical layering in WMS Get GetCapabilities.

### Maintenance
* Added unit testing for code coverage requirements.
* Reduced blind exceptions and replaced with specific exception catching.
* Sanitized error messaging to avoid exposing data to end-users.

## 0.5.0

### Introduction
Expand Down
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.
6 changes: 6 additions & 0 deletions example/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ def api_home(endpoint):
<li><a href="?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetLegendGraphic&LAYER={test_layer}&STYLE=default&FORMAT=image/png">WMS GetLegend Example (PNG)</a> <i>(v1.3.0)</i></li>
</ul>
</li>
<li> WMTS: Open Geospatial Consortium (OGC) Web Map Tile Service (WMTS) <i>(v1.0.0)</i>
<ul>
<li><a href="?SERVICE=WMTS&REQUEST=GetCapabilities&VERSION=1.0.0">WMTS GetCapabilities (XML)</a> <i>(v1.0.0)</i></li>
<li><a href="?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER={test_layer}&STYLES=&FORMAT=image%2Fpng&TILEMATRIXSET=WebMercatorQuad&TILEMATRIX=0&TILEROW=0&TILECOL=0">WMTS GetTile Example (PNG)</a> <i>(v1.0.0)</i></li>
</ul>
</li>
<li> EDR: Open Geospatial Consortium (OGC) Environmental Data Retrieval (EDR) <i>(v1.0.1)</i>
<ul>
<li><a href="{endpoint}/edr?f=html">EDR Landing Page (HTML)</a> <i>(v1.0.1)</i></li>
Expand Down
31 changes: 30 additions & 1 deletion ogc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
OGC WMS/WCS (v1.3.0/v1.0.0) server
"""

import re
import traitlets as tl
import datetime

Expand Down Expand Up @@ -67,10 +68,12 @@ class Layer(tl.HasTraits):
see podpac.Layer for example of a concrete class.
"""

safe_group_default = "Default"
identifier = tl.Unicode()
title = tl.Unicode(default_value="An OGC Layer")
abstract = tl.Unicode(default_value="This is an example OGC Layer")
group = tl.Unicode(default_value="Default")
group = tl.Unicode(default_value=safe_group_default)
group_path = tl.List(trait=tl.Unicode(default_value=None, allow_none=True))
is_fouo = tl.Bool(default_value=False)
grid_coordinates = tl.Instance(klass=GridCoordinates, default_value=GridCoordinates())
valid_times = tl.List(
Expand All @@ -84,6 +87,32 @@ class Layer(tl.HasTraits):
legend_graphic_height_inches = tl.Float(default_value=2.5) # inches
legend_graphic_dpi = tl.Float(default_value=100)

@tl.validate("group")
def _validate_group(self, proposal: dict) -> str:
"""Validate the group value to ensure it is URL safe, with a default fallback.

Allow only charcters from the following list (A-Z, a-z, 0-9, -, _)
Enforce a maximum length of 254 characters.

Parameters
----------
proposal: dict
The traitlet proposal for group.

Returns
-------
str
The sanitized group string or safe default value.
"""
validated_group = re.sub(r"[^-A-Za-z0-9_]", "-", proposal["value"])
validated_group = re.sub(r"-+", "-", validated_group)
validated_group = validated_group.strip("-")

if len(validated_group) > 0 and len(validated_group) < 255:
return validated_group

return self.safe_group_default

@property
def legend_graphic_width(self):
return int(self.legend_graphic_width_inches * self.legend_graphic_dpi)
Expand Down
85 changes: 65 additions & 20 deletions ogc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@
import gc
import logging
import traitlets as tl
from typing import Dict, Any

from . import settings
from . import wcs_request_1_0_0
from . import wms_request_1_3_0
from . import wcs_response_1_0_0
from . import wms_response_1_3_0
from .edr import EdrRoutes
from .wmts import WmtsRoutes

from ogc.ogc_common import WCSException
from ogc.ogc_common import WCSException, WMTSException

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -68,24 +70,36 @@ def __init__(self, layers=None, **kwargs):
if settings.EDR_ENABLED
else None
)
self.wmts_routes = (
WmtsRoutes(
coverages=coverages,
base_url=self.base_url,
service_title=self.service_title,
service_abstract=self.service_abstract,
service_group_title=self.service_group_title,
)
if settings.WMTS_ENABLED
else None
)

def get_coverage_from_id(self, identifier):
for coverage in self.wcs_capabilities.coverages:
if coverage.identifier == identifier:
return coverage
logger.warning("OGC: get_coverage_from_id invalid identifier: %r", identifier)
raise WCSException(
exception_code="InvalidParameterValue",
locator="COVERAGE",
exception_text="Invalid coverage {}".format(identifier),
exception_text="Invalid coverage identifier",
)

def get_capabilities_wcs(self, args):
get_capabilities = wcs_request_1_0_0.GetCapabilities()
try:
get_capabilities.load_from_kv(args)
get_capabilities.validate()
except Exception:
logger.error(LOAD_FAILURE, exc_info=True)
except Exception: # noqa: B902
logger.exception(LOAD_FAILURE)
raise WCSException(exception_text=INVALID_ARGUMENTS)

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

coverages = [self.get_coverage_from_id(identifier.value) for identifier in describe_coverage.identifiers]
Expand All @@ -114,8 +128,8 @@ def get_coverage_wcs(self, args, wcs_request):
try:
get_coverage.load_from_kv(args)
get_coverage.validate()
except Exception:
logger.error(LOAD_FAILURE, exc_info=True)
except Exception: # noqa: B902
logger.exception(LOAD_FAILURE)
raise WCSException(exception_text=INVALID_ARGUMENTS)

coverage = self.get_coverage_from_id(get_coverage.identifier.value)
Expand Down Expand Up @@ -159,10 +173,11 @@ def handle_wcs_kv(self, args):
wcs_response = wcs_response_1_0_0
wcs_request = wcs_request_1_0_0
else:
logger.warning("OGC: handle_wcs_kv unsupported version: %r", args.get("version"))
raise WCSException(
exception_code="InvalidParameterValue",
locator="VERSION",
exception_text="Unsupported version: %s" % (args["version"] if "version" in args else "None"),
exception_text="Unsupported version",
)

if args["request"] == "DescribeCoverage":
Expand All @@ -171,15 +186,16 @@ def handle_wcs_kv(self, args):
elif args["request"] == "GetCoverage":
return self.get_coverage_wcs(args, wcs_request)

raise WCSException(exception_text="KV Request not handled properly: " + str(args))
logger.warning("OGC: handle_wcs_kv unhandled request args: %r", args)
raise WCSException(exception_text=INVALID_ARGUMENTS)

def get_capabilities_wms(self, args):
get_capabilities = wms_request_1_3_0.GetCapabilities()
try:
get_capabilities.load_from_kv(args)
get_capabilities.validate()
except Exception:
logger.error(LOAD_FAILURE, exc_info=True)
except Exception: # noqa: B902
logger.exception(LOAD_FAILURE)
raise WCSException(exception_text=INVALID_ARGUMENTS)

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

coverage = self.get_coverage_from_id(get_legend_graphic.layer.value)
Expand All @@ -211,8 +227,8 @@ def get_map(self, args, wms_request):
try:
get_map.load_from_kv(args)
get_map.validate()
except Exception:
logger.error(LOAD_FAILURE, exc_info=True)
except Exception: # noqa: B902
logger.exception(LOAD_FAILURE)
raise WCSException(exception_text=INVALID_ARGUMENTS)

coverage = self.get_coverage_from_id(get_map.layer.value)
Expand Down Expand Up @@ -240,8 +256,8 @@ def get_map(self, args, wms_request):

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

fn = coverage.identifier.split(".")[-1] + ".png"
Expand All @@ -267,10 +283,11 @@ def handle_wms_kv(self, args):
if "version" in args and args["version"] == "1.3.0":
wms_request = wms_request_1_3_0
else:
logger.warning("OGC: handle_wms_kv unsupported version: %r", args.get("version"))
raise WCSException(
exception_code="InvalidParameterValue",
locator="VERSION",
exception_text="Unsupported version: %s" % (args["version"] if "version" in args else "None"),
exception_text="Unsupported version",
)

if args["request"].lower() == "getlegendgraphic":
Expand All @@ -279,4 +296,32 @@ def handle_wms_kv(self, args):
if args["request"].lower() == "getmap":
return self.get_map(args, wms_request)

raise WCSException(exception_text="KV Request not handled properly: " + str(args))
logger.warning("OGC: handle_wms_kv unhandled request args: %r", args)
raise WCSException(exception_text=INVALID_ARGUMENTS)

def handle_wmts_kv(self, args: Dict[str, Any]) -> Dict[str, Any] | str:
"""Handle WMTS key value server requests if support is enabled.

Parameters
----------
args : Dict[str, Any]
The filtered request arguments.

Returns
-------
Dict[str, Any] | str
A dictionary containing the tile response or a string of service metadata.

Raises
------
WMTSException
Exception for errors related to WMTS operations.
"""
if self.wmts_routes is None:
raise WMTSException(
exception_code="OperationNotSupported",
locator="REQUEST",
exception_text="Unsupported request",
)

return self.wmts_routes.handle_kv(args)
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
Loading
Loading