Skip to content

Commit 6085edc

Browse files
committed
fix support for custom formatters (#1997)
1 parent 21b0f11 commit 6085edc

11 files changed

Lines changed: 168 additions & 49 deletions

File tree

docs/source/configuration.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ default.
257257
storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 # optional CRS in which data is stored, default: as 'crs' field
258258
storage_crs_coordinate_epoch: 2017.23 # optional, if storage_crs is a dynamic coordinate reference system
259259
always_xy: false # optional should CRS respect axis ordering
260+
formatters: # list of 1..n formatter definitions
261+
- name: path.to.formatter # Python path of formatter definition
262+
attachment: true # whether or not to provide as an attachment or normal response
263+
geom: false # whether or not to include geometry
260264
261265
hello-world: # name of process
262266
type: process # REQUIRED (collection, process, or stac-collection)

docs/source/plugins.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,10 @@ The below template provides a minimal example (let's call the file ``mycooljsonf
435435
"""Inherit from parent class"""
436436
437437
super().__init__({'name': 'cooljson', 'geom': None})
438-
self.mimetype = 'application/json; subtype:mycooljson'
438+
self.f = 'cooljson' # f= value
439+
self.mimetype = 'application/json; subtype:mycooljson' # response media type
440+
self.attachment = False # whether to provide as an attachment (default False)
441+
self.extension = 'cooljson' # filename extension if providing as an attachment
439442
440443
def write(self, options={}, data=None):
441444
"""custom writer"""

pygeoapi/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def decorator(click_group):
6868
try:
6969
click_group.add_command(entry_point.load())
7070
except Exception as err:
71-
print(err)
71+
click.echo(err)
7272
return click_group
7373

7474
return decorator

pygeoapi/api/__init__.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@
6868
TEMPLATESDIR, UrlPrefetcher, dategetter,
6969
filter_dict_by_key_value, filter_providers_by_type, get_api_rules,
7070
get_base_url, get_provider_by_type, get_provider_default, get_typed_value,
71-
render_j2_template, to_json, get_choice_from_headers, get_from_headers
71+
render_j2_template, to_json, get_choice_from_headers, get_from_headers,
72+
get_dataset_formatters
7273
)
7374

7475
LOGGER = logging.getLogger(__name__)
@@ -319,11 +320,14 @@ def _get_locale(self, headers: dict,
319320

320321
return raw, default_locale
321322

322-
def _get_format(self, headers: dict) -> Union[str, None]:
323+
def _get_format(self, headers: dict,
324+
extra_formats: dict = {}) -> Union[str, None]:
323325
"""
324326
Get `Request` format type from query parameters or headers.
325327
326328
:param headers: Dict of Request headers
329+
:param extra_formats: Dict of extra dataset specific formats
330+
327331
:returns: format value or None if not found/specified
328332
"""
329333

@@ -339,10 +343,14 @@ def _get_format(self, headers: dict) -> Union[str, None]:
339343
if types_ is None:
340344
return
341345

342-
(fmts, mimes) = zip(*FORMAT_TYPES.items())
346+
merged_format_types = FORMAT_TYPES | extra_formats
347+
348+
(fmts, mimes) = zip(*merged_format_types.items())
349+
mimes2 = [m.split(';')[0] for m in mimes]
350+
343351
for type_ in types_:
344-
if type_ in mimes:
345-
idx_ = mimes.index(type_)
352+
if type_ in mimes2:
353+
idx_ = mimes2.index(type_)
346354
return fmts[idx_]
347355

348356
@property
@@ -1042,6 +1050,14 @@ def describe_collections(api: API, request: APIRequest,
10421050
'href': f'{api.get_collections_url()}/{k}/items?f={F_HTML}' # noqa
10431051
})
10441052

1053+
for key, value in get_dataset_formatters(v).items():
1054+
collection['links'].append({
1055+
'type': value.mimetype,
1056+
'rel': 'items',
1057+
'title': l10n.translate(f'Items as {key}', request.locale), # noqa
1058+
'href': f'{api.get_collections_url()}/{k}/items?f={value.f}' # noqa
1059+
})
1060+
10451061
# OAPIF Part 2 - list supported CRSs and StorageCRS
10461062
if collection_data_type in ['edr', 'feature']:
10471063
collection['crs'] = get_supported_crs_list(collection_data)

pygeoapi/api/itemtypes.py

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
# Colin Blackburn <[email protected]>
88
# Ricardo Garcia Silva <[email protected]>
99
#
10-
# Copyright (c) 2025 Tom Kralidis
10+
# Copyright (c) 2026 Tom Kralidis
1111
# Copyright (c) 2025 Francesco Bartoli
1212
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
1313
# Copyright (c) 2023 Ricardo Garcia Silva
@@ -55,13 +55,15 @@
5555
set_content_crs_header)
5656
from pygeoapi.formatter.base import FormatterSerializationError
5757
from pygeoapi.linked_data import geojson2jsonld
58+
from pygeoapi.openapi import get_oas_30_parameters
5859
from pygeoapi.plugin import load_plugin, PLUGINS
5960
from pygeoapi.provider.base import (
6061
ProviderGenericError, ProviderTypeError, SchemaType)
6162

6263
from pygeoapi.util import (filter_providers_by_type, to_json,
6364
filter_dict_by_key_value, str2bool,
64-
get_provider_by_type, render_j2_template)
65+
get_provider_by_type, render_j2_template,
66+
get_dataset_formatters)
6567

6668
from . import (
6769
APIRequest, API, SYSTEM_LOCALE, F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD,
@@ -241,9 +243,6 @@ def get_collection_items(
241243
:returns: tuple of headers, status code, content
242244
"""
243245

244-
if not request.is_valid(PLUGINS['formatter'].keys()):
245-
return api.get_format_exception(request)
246-
247246
# Set Content-Language to system locale until provider locale
248247
# has been determined
249248
headers = request.get_response_headers(SYSTEM_LOCALE,
@@ -352,6 +351,20 @@ def get_collection_items(
352351
err.http_status_code, headers, request.format,
353352
err.ogc_exception_code, err.message)
354353

354+
LOGGER.debug('Validating requested format')
355+
dataset_formatters = get_dataset_formatters(collections[dataset])
356+
357+
if dataset_formatters:
358+
LOGGER.debug(f'Dataset formatters: {dataset_formatters}')
359+
request._format = request._get_format(
360+
request.get_request_headers(request.headers),
361+
{v.f: v.mimetype for v in dataset_formatters.values()})
362+
363+
LOGGER.debug(f'Request format: {request.format}')
364+
365+
if not request.is_valid(dataset_formatters.keys()):
366+
return api.get_format_exception(request)
367+
355368
crs_transform_spec = None
356369
if provider_type == 'feature':
357370
# crs query parameter is only available for OGC API - Features
@@ -581,6 +594,14 @@ def get_collection_items(
581594
'href': f'{uri}?f={F_HTML}{serialized_query_params}'
582595
}])
583596

597+
for key, value in dataset_formatters.items():
598+
content['links'].append({
599+
'type': value.mimetype,
600+
'rel': 'alternate',
601+
'title': f'This document as {key}',
602+
'href': f'{uri}?f={value.name}{serialized_query_params}'
603+
})
604+
584605
next_link = False
585606
prev_link = False
586607

@@ -656,9 +677,9 @@ def get_collection_items(
656677
'collections/items/index.html',
657678
content, request.locale)
658679
return headers, HTTPStatus.OK, content
659-
elif request.format == 'csv': # render
660-
formatter = load_plugin('formatter',
661-
{'name': 'CSV', 'geom': True})
680+
elif request.format in [df.f for df in dataset_formatters.values()]:
681+
formatter = [v for v in dataset_formatters.values() if
682+
v.f == request.format][0]
662683

663684
try:
664685
content = formatter.write(
@@ -677,13 +698,14 @@ def get_collection_items(
677698

678699
headers['Content-Type'] = formatter.mimetype
679700

680-
if p.filename is None:
681-
filename = f'{dataset}.csv'
682-
else:
683-
filename = f'{p.filename}'
701+
if formatter.attachment:
702+
if p.filename is None:
703+
filename = f'{dataset}.{formatter.extension}'
704+
else:
705+
filename = f'{p.filename}'
684706

685-
cd = f'attachment; filename="{filename}"'
686-
headers['Content-Disposition'] = cd
707+
cd = f'attachment; filename="{filename}"'
708+
headers['Content-Disposition'] = cd
687709

688710
return headers, HTTPStatus.OK, content
689711

@@ -1073,14 +1095,19 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str,
10731095
v.get('limits', {})
10741096
)
10751097

1098+
dataset_formatters = get_dataset_formatters(v)
1099+
coll_f_parameter = deepcopy(get_oas_30_parameters(cfg, locale))['f'] # noqa
1100+
for key, value in dataset_formatters.items():
1101+
coll_f_parameter['schema']['enum'].append(value.f)
1102+
10761103
paths[items_path] = {
10771104
'get': {
10781105
'summary': f'Get {title} items',
10791106
'description': description,
10801107
'tags': [k],
10811108
'operationId': f'get{k.capitalize()}Features',
10821109
'parameters': [
1083-
{'$ref': '#/components/parameters/f'},
1110+
coll_f_parameter,
10841111
{'$ref': '#/components/parameters/lang'},
10851112
{'$ref': '#/components/parameters/bbox'},
10861113
coll_limit,

pygeoapi/formatter/base.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# Authors: Tom Kralidis <[email protected]>
44
#
5-
# Copyright (c) 2022 Tom Kralidis
5+
# Copyright (c) 2026 Tom Kralidis
66
#
77
# Permission is hereby granted, free of charge, to any person
88
# obtaining a copy of this software and associated documentation
@@ -39,23 +39,28 @@ def __init__(self, formatter_def: dict):
3939
"""
4040
Initialize object
4141
42-
:param formatter_def: formatter definition
42+
param formatter_def: formatter definition
4343
4444
:returns: pygeoapi.formatter.base.BaseFormatter
4545
"""
4646

47+
self.extension = None
48+
self.f = None
4749
self.mimetype = None
48-
self.geom = False
4950

50-
self.name = formatter_def['name']
51-
if 'geom' in formatter_def:
52-
self.geom = formatter_def['geom']
51+
try:
52+
self.name = formatter_def['name']
53+
except KeyError:
54+
raise RuntimeError('name is required')
55+
56+
self.geom = formatter_def.get('geom', False)
57+
self.attachment = formatter_def.get('attachment', False)
5358

5459
def write(self, options: dict = {}, data: dict | None = None) -> str:
5560
"""
5661
Generate data in specified format
5762
58-
:param options: CSV formatting options
63+
:param options: formatting options
5964
:param data: dict representation of GeoJSON object
6065
6166
:returns: string representation of format

pygeoapi/formatter/csv_.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,12 @@ def __init__(self, formatter_def: dict):
4848
:returns: `pygeoapi.formatter.csv_.CSVFormatter`
4949
"""
5050

51-
geom = False
52-
if 'geom' in formatter_def:
53-
geom = formatter_def['geom']
51+
geom = formatter_def.get('geom', False)
5452

5553
super().__init__({'name': 'csv', 'geom': geom})
5654
self.mimetype = 'text/csv; charset=utf-8'
55+
self.f = 'csv'
56+
self.extension = 'csv'
5757

5858
def write(self, options: dict = {}, data: dict = None) -> str:
5959
"""

pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,25 @@ properties:
583583
- type
584584
- name
585585
- data
586+
formatters:
587+
type: array
588+
description: custom formatters to apply to output
589+
items:
590+
type: object
591+
properties:
592+
name:
593+
type: string
594+
description: name of formatter
595+
geom:
596+
type: boolean
597+
default: true
598+
description: whether to include geometry
599+
attachment:
600+
type: boolean
601+
default: false
602+
description: whether to provide as an attachment
603+
required:
604+
- name
586605
required:
587606
- type
588607
- title

pygeoapi/util.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
from pygeoapi import __version__
6565
from pygeoapi import l10n
6666
from pygeoapi.models import config as config_models
67+
from pygeoapi.plugin import load_plugin, PLUGINS
6768
from pygeoapi.provider.base import ProviderTypeError
6869

6970

@@ -764,3 +765,24 @@ def get_choice_from_headers(headers: dict,
764765

765766
# Return one or all choices
766767
return sorted_choices if all else sorted_choices[0]
768+
769+
770+
def get_dataset_formatters(dataset: dict) -> dict:
771+
"""
772+
Helper function to derive all formatters for an itemtype
773+
774+
:param dataset: `dict` of dataset resource definition
775+
776+
:returns: `dict` of formatters
777+
"""
778+
779+
dataset_formatters = {}
780+
781+
for key, value in PLUGINS['formatter'].items():
782+
df2 = load_plugin('formatter', {'name': key})
783+
dataset_formatters[key] = df2
784+
for df in dataset.get('formatters', []):
785+
df2 = load_plugin('formatter', df)
786+
dataset_formatters[df2.name] = df2
787+
788+
return dataset_formatters

tests/api/test_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,7 @@ def test_describe_collections(config, api_):
591591
assert collection['id'] == 'obs'
592592
assert collection['title'] == 'Observations'
593593
assert collection['description'] == 'My cool observations'
594-
assert len(collection['links']) == 14
594+
assert len(collection['links']) == 15
595595
assert collection['extent'] == {
596596
'spatial': {
597597
'bbox': [[-180, -90, 180, 90]],
@@ -682,7 +682,7 @@ def test_describe_collections_json_ld(config, api_):
682682
assert len(expanded['http://schema.org/dataset']) == 1
683683
dataset = expanded['http://schema.org/dataset'][0]
684684
assert dataset['@type'][0] == 'http://schema.org/Dataset'
685-
assert len(dataset['http://schema.org/distribution']) == 14
685+
assert len(dataset['http://schema.org/distribution']) == 15
686686
assert all(dist['@type'][0] == 'http://schema.org/DataDownload'
687687
for dist in dataset['http://schema.org/distribution'])
688688

0 commit comments

Comments
 (0)