Coverage for pygeoapi/api/__init__.py: 67%

508 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 08:15 +0000

1# ================================================================= 

2# 

3# Authors: Tom Kralidis <tomkralidis@gmail.com> 

4# Francesco Bartoli <xbartolone@gmail.com> 

5# Sander Schaminee <sander.schaminee@geocat.net> 

6# John A Stevenson <jostev@bgs.ac.uk> 

7# Colin Blackburn <colb@bgs.ac.uk> 

8# Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it> 

9# 

10# Copyright (c) 2026 Tom Kralidis 

11# Copyright (c) 2026 Francesco Bartoli 

12# Copyright (c) 2022 John A Stevenson and Colin Blackburn 

13# Copyright (c) 2023 Ricardo Garcia Silva 

14# 

15# Permission is hereby granted, free of charge, to any person 

16# obtaining a copy of this software and associated documentation 

17# files (the "Software"), to deal in the Software without 

18# restriction, including without limitation the rights to use, 

19# copy, modify, merge, publish, distribute, sublicense, and/or sell 

20# copies of the Software, and to permit persons to whom the 

21# Software is furnished to do so, subject to the following 

22# conditions: 

23# 

24# The above copyright notice and this permission notice shall be 

25# included in all copies or substantial portions of the Software. 

26# 

27# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 

28# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 

29# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 

30# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 

31# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 

32# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 

33# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 

34# OTHER DEALINGS IN THE SOFTWARE. 

35# 

36# ================================================================= 

37 

38""" 

39Root level code of pygeoapi, parsing content provided by web framework. 

40Returns content from plugins and sets responses. 

41""" 

42 

43from collections import ChainMap 

44from copy import deepcopy 

45from datetime import datetime 

46from functools import partial 

47from gzip import compress 

48from http import HTTPStatus 

49import logging 

50import re 

51import sys 

52from typing import Any, Tuple, Union, Self 

53 

54from babel import Locale 

55from dateutil.parser import parse as dateparse 

56import pytz 

57 

58from pygeoapi import __version__, l10n 

59from pygeoapi.api.collection import gen_collection, OGC_RELTYPES_BASE 

60from pygeoapi.formats import FORMAT_TYPES, F_GZIP, F_HTML, F_JSON, F_JSONLD 

61from pygeoapi.linked_data import jsonldify, jsonldify_collection 

62from pygeoapi.log import setup_logger 

63from pygeoapi.plugin import load_plugin 

64from pygeoapi.process.manager.base import get_manager 

65from pygeoapi.provider import filter_providers_by_type, get_provider_by_type 

66from pygeoapi.provider.base import ProviderGenericError, ProviderTypeError 

67 

68from pygeoapi.util import ( 

69 TEMPLATESDIR, UrlPrefetcher, filter_dict_by_key_value, get_api_rules, 

70 get_base_url, get_typed_value, render_j2_template, to_json, 

71 get_choice_from_headers, get_from_headers 

72) 

73 

74LOGGER = logging.getLogger(__name__) 

75 

76#: Return headers for requests (e.g:X-Powered-By) 

77HEADERS = { 

78 'Content-Type': 'application/json', 

79 'X-Powered-By': f'pygeoapi {__version__}' 

80} 

81 

82CHARSET = ['utf-8'] 

83 

84#: Locale used for system responses (e.g. exceptions) 

85SYSTEM_LOCALE = l10n.Locale('en', 'US') 

86 

87CONFORMANCE_CLASSES = [ 

88 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core', 

89 'http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections', 

90 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/landing-page', 

91 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json', 

92 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/html', 

93 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30' 

94] 

95 

96 

97def all_apis() -> dict: 

98 """ 

99 Return all supported API modules 

100 

101 NOTE: this is a function and not a constant to avoid import loops 

102 

103 :returns: `dict` of API provider type, API module 

104 """ 

105 

106 from . import (coverages, environmental_data_retrieval, itemtypes, maps, 

107 processes, pubsub, tiles, stac) 

108 

109 return { 

110 'coverage': coverages, 

111 'edr': environmental_data_retrieval, 

112 'itemtypes': itemtypes, 

113 'map': maps, 

114 'process': processes, 

115 'pubsub': pubsub, 

116 'tile': tiles, 

117 'stac': stac 

118 } 

119 

120 

121def apply_gzip(headers: dict, content: Union[str, bytes]) -> Union[str, bytes]: 

122 """ 

123 Compress content if requested in header. 

124 """ 

125 charset = CHARSET[0] 

126 

127 if F_GZIP in get_from_headers(headers, 'content-encoding'): 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true

128 try: 

129 if isinstance(content, bytes): 

130 # bytes means Content-Type needs to be set upstream 

131 content = compress(content) 

132 else: 

133 headers['Content-Type'] = \ 

134 f"{headers['Content-Type']}; charset={charset}" 

135 content = compress(content.encode(charset)) 

136 except TypeError as err: 

137 headers.pop('Content-Encoding') 

138 LOGGER.error(f'Error in compression: {err}') 

139 return content 

140 

141 

142class APIRequest: 

143 """ 

144 Transforms an incoming server-specific Request into an object 

145 with some generic helper methods and properties. 

146 

147 This allows writing straightforward API functions supporting all 

148 web platforms such as with this example: 

149 

150 .. code-block:: python 

151 

152 def example_method(api: API, request: APIRequest, custom_arg): 

153 headers = request.get_response_headers() 

154 

155 # generate response_body here 

156 

157 return headers, HTTPStatus.OK, response_body 

158 

159 

160 Basic request validation is done automatically by web platform specific 

161 adapters such as ``execute_from_flask`` in flask_app.py . If you want to 

162 support custom formats (e.g. ``f=xml``), it needs to be registered with 

163 `skip_valid_check=True` and you can use the following code for custom 

164 validation. If `xml` was requested, we set the `Content-Type` ourselves. 

165 For the standard formats, the `APIRequest` object sets the `Content-Type`. 

166 

167 .. code-block:: python 

168 

169 def example_method(api: API, request: APIRequest, custom_arg): 

170 if not request.is_valid(['xml']): 

171 return api.get_format_exception(request) 

172 

173 content_type = 'application/xml' if request.format == 'xml' else None 

174 headers = request.get_response_headers(content_type) 

175 

176 # generate response_body here 

177 

178 return headers, HTTPStatus.OK, response_body 

179 

180 Note that you don't *have* to call :meth:`is_valid`, but that you can also 

181 perform a custom check on the requested output format by looking at the 

182 :attr:`format` property. 

183 Other query parameters are available through the :attr:`params` property as 

184 a `dict`. The request body is available through the :attr:`data` property. 

185 

186 :param request: The web platform specific Request instance. 

187 :param supported_locales: List or set of supported Locale instances. 

188 """ 

189 

190 def __init__(self, request, supported_locales): 

191 # Set default request data 

192 self._data = b'' 

193 

194 # Copy request query parameters 

195 self._args = self._get_params(request) 

196 

197 # Get path info 

198 if hasattr(request, 'scope'): 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true

199 self._path_info = request.scope['path'].strip('/') 

200 elif hasattr(request.headers, 'environ'): 200 ↛ 202line 200 didn't jump to line 202 because the condition on line 200 was always true

201 self._path_info = request.headers.environ['PATH_INFO'].strip('/') 

202 elif hasattr(request, 'path_info'): 

203 self._path_info = request.path_info 

204 

205 # Extract locale from params or headers 

206 self._raw_locale, self._locale = self._get_locale(request.headers, 

207 supported_locales) 

208 

209 # Determine format 

210 self._format = self._get_format(request.headers) 

211 

212 # Get received headers 

213 self._headers = self.get_request_headers(request.headers) 

214 

215 @classmethod 

216 def from_flask(cls, request, supported_locales) -> 'APIRequest': 

217 """Factory class similar to with_data, but only for flask requests""" 

218 api_req = cls(request, supported_locales) 

219 api_req._data = request.data 

220 return api_req 

221 

222 @classmethod 

223 async def from_starlette(cls, request, supported_locales) -> 'APIRequest': 

224 """Factory class similar to with_data, but only for starlette requests 

225 """ 

226 api_req = cls(request, supported_locales) 

227 api_req._data = await request.body() 

228 return api_req 

229 

230 @classmethod 

231 def from_django(cls, request, supported_locales) -> 'APIRequest': 

232 """Factory class similar to with_data, but only for django requests""" 

233 api_req = cls(request, supported_locales) 

234 api_req._data = request.body 

235 return api_req 

236 

237 @staticmethod 

238 def _get_params(request): 

239 """ 

240 Extracts the query parameters from the `Request` object. 

241 

242 :param request: A Flask or Starlette Request instance 

243 :returns: `ImmutableMultiDict` or empty `dict` 

244 """ 

245 

246 if hasattr(request, 'args'): 246 ↛ 249line 246 didn't jump to line 249 because the condition on line 246 was always true

247 # Return ImmutableMultiDict from Flask request 

248 return request.args 

249 elif hasattr(request, 'query_params'): 

250 # Return ImmutableMultiDict from Starlette request 

251 return request.query_params 

252 elif hasattr(request, 'GET'): 

253 # Return QueryDict from Django GET request 

254 return request.GET 

255 elif hasattr(request, 'POST'): 

256 # Return QueryDict from Django GET request 

257 return request.POST 

258 LOGGER.debug('No query parameters found') 

259 return {} 

260 

261 def _get_locale(self, headers: dict, 

262 supported_locales: Union[list, Locale]) -> tuple: 

263 """ 

264 Detects locale from "lang=<language>" param or `Accept-Language` 

265 header. Returns a tuple of (raw, locale) if found in params or headers. 

266 Returns a tuple of (raw default, default locale) if not found. 

267 

268 :param headers: A dict with Request headers 

269 :param supported_locales: List or set of supported Locale instances 

270 :returns: A tuple of (str, Locale) 

271 """ 

272 

273 raw = None 

274 try: 

275 default_locale = l10n.str2locale(supported_locales[0]) 

276 except (TypeError, IndexError, l10n.LocaleError) as err: 

277 # This should normally not happen, since the API class already 

278 # loads the supported languages from the config, which raises 

279 # a LocaleError if any of these languages are invalid. 

280 LOGGER.error(err) 

281 raise ValueError(f"{self.__class__.__name__} must be initialized" 

282 f"with a list of valid supported locales") 

283 

284 for field, mapping in ((l10n.QUERY_PARAM, self._args), 

285 ('accept-language', headers)): 

286 

287 loc_strs = get_choice_from_headers(mapping, field, all=True) 

288 if loc_strs: 

289 if raw is None: 289 ↛ 294line 289 didn't jump to line 294 because the condition on line 289 was always true

290 # This is the first-found locale string: set as raw 

291 raw = get_from_headers(mapping, field) 

292 

293 # Check if locale string is a good match for the UI 

294 loc = l10n.best_match(loc_strs, supported_locales) 

295 is_override = field is l10n.QUERY_PARAM 

296 if loc != default_locale or is_override: 296 ↛ 284line 296 didn't jump to line 284 because the condition on line 296 was always true

297 return raw, loc 

298 

299 return raw, default_locale 

300 

301 def _get_format(self, headers: dict, 

302 extra_formats: dict = {}) -> Union[str, None]: 

303 """ 

304 Get `Request` format type from query parameters or headers. 

305 

306 :param headers: Dict of Request headers 

307 :param extra_formats: Dict of extra dataset specific formats 

308 

309 :returns: format value or None if not found/specified 

310 """ 

311 

312 # Optional f=html or f=json query param 

313 # Overrides Accept header and might differ from FORMAT_TYPES 

314 format_ = (self._args.get('f') or '').strip() 

315 if format_: 

316 return format_ 

317 

318 # Format not specified: get from Accept headers (MIME types) 

319 # e.g. Accept: 'text/html;q=0.5,application/ld+json' 

320 types_ = get_choice_from_headers(headers, 'accept', all=True) 

321 if types_ is None: 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true

322 return 

323 

324 merged_format_types = FORMAT_TYPES | extra_formats 

325 

326 (fmts, mimes) = zip(*merged_format_types.items()) 

327 mimes2 = [m.split(';')[0] for m in mimes] 

328 

329 for type_ in types_: 

330 if type_ in mimes2: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true

331 idx_ = mimes2.index(type_) 

332 return fmts[idx_] 

333 

334 @property 

335 def data(self) -> bytes: 

336 """Returns the additional data send with the Request (bytes)""" 

337 return self._data 

338 

339 @property 

340 def params(self) -> dict: 

341 """Returns the Request query parameters dict""" 

342 return self._args 

343 

344 @property 

345 def path_info(self) -> str: 

346 """Returns the web server request path info part""" 

347 return self._path_info 

348 

349 @property 

350 def locale(self) -> l10n.Locale: 

351 """ 

352 Returns the user-defined locale from the request object. 

353 If no locale has been defined or if it is invalid, 

354 the default server locale is returned. 

355 

356 .. note:: The locale here determines the language in which pygeoapi 

357 should return its responses. This may not be the language 

358 that the user requested. It may also not be the language 

359 that is supported by a collection provider, for example. 

360 For this reason, you should pass the `raw_locale` property 

361 to the :func:`l10n.get_plugin_locale` function, so that 

362 the best match for the provider can be determined. 

363 

364 :returns: babel.core.Locale 

365 """ 

366 

367 return self._locale 

368 

369 @property 

370 def raw_locale(self) -> Union[str, None]: 

371 """ 

372 Returns the raw locale string from the `Request` object. 

373 If no "lang" query parameter or `Accept-Language` header was found, 

374 `None` is returned. 

375 Pass this value to the :func:`l10n.get_plugin_locale` function to let 

376 the provider determine a best match for the locale, which may be 

377 different from the locale used by pygeoapi's UI. 

378 

379 :returns: a locale string or None 

380 """ 

381 

382 return self._raw_locale 

383 

384 @property 

385 def format(self) -> Union[str, None]: 

386 """ 

387 Returns the content type format from the 

388 request query parameters or headers. 

389 

390 :returns: Format name or None 

391 """ 

392 

393 return self._format 

394 

395 @property 

396 def headers(self) -> dict: 

397 """ 

398 Returns the dictionary of the headers from 

399 the request. 

400 

401 :returns: Request headers dictionary 

402 """ 

403 

404 return self._headers 

405 

406 def get_linkrel(self, format_: str) -> str: 

407 """ 

408 Returns the hyperlink relationship (rel) attribute value for 

409 the given API format string. 

410 

411 The string is compared against the request format and if it matches, 

412 the value 'self' is returned. Otherwise, 'alternate' is returned. 

413 However, if `format_` is 'json' and *no* request format was found, 

414 the relationship 'self' is returned as well (JSON is the default). 

415 

416 :param format_: The format to compare the request format against. 

417 :returns: A string 'self' or 'alternate'. 

418 """ 

419 

420 fmt = format_.lower() 

421 if fmt == self._format or (fmt == F_JSON and not self._format): 

422 return 'self' 

423 return 'alternate' 

424 

425 def is_valid(self, additional_formats=None) -> bool: 

426 """ 

427 Returns True if: 

428 - the format is not set (None) 

429 - the requested format is supported 

430 - the requested format exists in a list if additional formats 

431 

432 .. note:: Format names are matched in a case-insensitive manner. 

433 

434 :param additional_formats: Optional additional supported formats list 

435 

436 :returns: bool 

437 """ 

438 

439 if not self._format: 

440 return True 

441 if self._format in FORMAT_TYPES.keys(): 

442 return True 

443 if self._format in (f.lower() for f in (additional_formats or ())): 

444 return True 

445 return False 

446 

447 def get_response_headers(self, force_lang: l10n.Locale | None = None, 

448 force_type: str | None = None, 

449 force_encoding: str | None = None, 

450 **custom_headers) -> dict: 

451 """ 

452 Prepares and returns a dictionary with Response object headers. 

453 

454 This method always adds a 'Content-Language' header, where the value 

455 is determined by the 'lang' query parameter or 'Accept-Language' 

456 header from the request. 

457 If no language was requested, the default pygeoapi language is used, 

458 unless a `force_lang` override was specified (see notes below). 

459 

460 A 'Content-Type' header is also always added to the response. 

461 If the user does not specify `force_type`, the header is based on 

462 the `format` APIRequest property. If that is invalid, the default MIME 

463 type `application/json` is used. 

464 

465 ..note:: If a `force_lang` override is applied, that language 

466 is always set as the 'Content-Language', regardless of 

467 a 'lang' query parameter or 'Accept-Language' header. 

468 If an API response always needs to be in the same 

469 language, 'force_lang' should be set to that language. 

470 

471 :param force_lang: An optional Content-Language header override. 

472 :param force_type: An optional Content-Type header override. 

473 :param force_encoding: An optional Content-Encoding header override. 

474 :returns: A header dict 

475 """ 

476 

477 headers = HEADERS.copy() 

478 headers.update(**custom_headers) 

479 l10n.set_response_language(headers, force_lang or self._locale) 

480 if force_type: 480 ↛ 482line 480 didn't jump to line 482 because the condition on line 480 was never true

481 # Set custom MIME type if specified 

482 headers['Content-Type'] = force_type 

483 elif self.is_valid() and self._format: 

484 # Set MIME type for valid formats 

485 headers['Content-Type'] = FORMAT_TYPES[self._format] 

486 

487 if F_GZIP in FORMAT_TYPES: 487 ↛ 488line 487 didn't jump to line 488 because the condition on line 487 was never true

488 if force_encoding: 

489 headers['Content-Encoding'] = force_encoding 

490 elif F_GZIP in get_from_headers(self._headers, 'accept-encoding'): # noqa 

491 headers['Content-Encoding'] = F_GZIP 

492 

493 return headers 

494 

495 def get_request_headers(self, headers: dict) -> dict: 

496 """ 

497 Obtains and returns a dictionary with Request object headers. 

498 

499 This method adds the headers of the original request and 

500 makes them available to the API object. 

501 

502 :returns: A header dict 

503 """ 

504 

505 headers_ = {item[0]: item[1] for item in headers.items()} 

506 return headers_ 

507 

508 

509class API: 

510 """API object""" 

511 

512 def __init__(self, config: dict, openapi: dict, 

513 asyncapi: dict = {}) -> Self | None: 

514 """ 

515 constructor 

516 

517 :param config: configuration dict 

518 :param openapi: openapi dict 

519 :param asyncapi: asyncapi dict 

520 

521 :returns: `pygeoapi.API` instance 

522 """ 

523 

524 self.config = config 

525 self.openapi = openapi 

526 self.asyncapi = asyncapi 

527 self.api_headers = get_api_rules(self.config).response_headers 

528 self.base_url = get_base_url(self.config) 

529 self.prefetcher = UrlPrefetcher() 

530 self.pubsub_client = None 

531 

532 CHARSET[0] = config['server'].get('encoding', 'utf-8') 

533 if config['server'].get('gzip'): 533 ↛ 534line 533 didn't jump to line 534 because the condition on line 533 was never true

534 FORMAT_TYPES[F_GZIP] = 'application/gzip' 

535 FORMAT_TYPES.move_to_end(F_JSON) 

536 

537 # Process language settings (first locale is default!) 

538 self.locales = l10n.get_locales(config) 

539 self.default_locale = self.locales[0] 

540 

541 if 'templates' not in self.config['server']: 541 ↛ 544line 541 didn't jump to line 544 because the condition on line 541 was always true

542 self.config['server']['templates'] = {'path': TEMPLATESDIR} 

543 

544 if 'pretty_print' not in self.config['server']: 544 ↛ 545line 544 didn't jump to line 545 because the condition on line 544 was never true

545 self.config['server']['pretty_print'] = False 

546 

547 self.pretty_print = self.config['server']['pretty_print'] 

548 

549 setup_logger(self.config['logging']) 

550 

551 # Create config clone for HTML templating with modified base URL 

552 self.tpl_config = deepcopy(self.config) 

553 self.tpl_config['server']['url'] = self.base_url 

554 

555 self.manager = get_manager(self.config) 

556 LOGGER.info('Process manager plugin loaded') 

557 

558 if self.config.get('pubsub') is not None: 558 ↛ 559line 558 didn't jump to line 559 because the condition on line 558 was never true

559 LOGGER.debug('Loading PubSub client') 

560 self.pubsub_client = load_plugin('pubsub', self.config['pubsub']) 

561 

562 def get_exception(self, status: int, headers: dict, format_: str | None, 

563 code: str, description: str) -> Tuple[dict, int, str]: 

564 """ 

565 Exception handler 

566 

567 :param status: HTTP status code 

568 :param headers: dict of HTTP response headers 

569 :param format_: format string 

570 :param code: OGC API exception code 

571 :param description: OGC API exception code 

572 

573 :returns: tuple of headers, status, and message 

574 """ 

575 

576 exception_info = sys.exc_info() 

577 

578 LOGGER.error( 

579 description, 

580 exc_info=exception_info if exception_info[0] is not None else None 

581 ) 

582 exception = { 

583 'code': code, 

584 'type': code, 

585 'description': description 

586 } 

587 

588 if format_ == F_HTML: 

589 headers['Content-Type'] = FORMAT_TYPES[F_HTML] 

590 content = render_j2_template( 

591 self.tpl_config, self.config['server']['templates'], 

592 'exception.html', exception, SYSTEM_LOCALE) 

593 else: 

594 content = to_json(exception, self.pretty_print) 

595 

596 if status == HTTPStatus.NO_CONTENT: 596 ↛ 597line 596 didn't jump to line 597 because the condition on line 596 was never true

597 LOGGER.error('HTTP 204 detected, suppressing content') 

598 content = '' 

599 

600 return headers, status, content 

601 

602 def get_format_exception(self, 

603 request: APIRequest) -> Tuple[dict, int, str]: 

604 """ 

605 Returns a format exception. 

606 

607 :param request: An APIRequest instance. 

608 

609 :returns: tuple of (headers, status, message) 

610 """ 

611 

612 # Content-Language is in the system locale (ignore language settings) 

613 headers = request.get_response_headers(SYSTEM_LOCALE, 

614 **self.api_headers) 

615 msg = 'Invalid format requested' 

616 LOGGER.error(f'{msg}: {request.format}') 

617 return self.get_exception( 

618 HTTPStatus.BAD_REQUEST, headers, 

619 request.format, 'InvalidParameterValue', msg) 

620 

621 def get_collections_url(self) -> str: 

622 return f"{self.base_url}/collections" 

623 

624 def get_dataset_templates(self, dataset: str) -> dict: 

625 templates = self.config['resources'][dataset].get('templates') 

626 

627 return templates or self.tpl_config['server']['templates'] 

628 

629 

630@jsonldify 

631def landing_page(api: API, 

632 request: APIRequest) -> Tuple[dict, int, str]: 

633 """ 

634 Provide API landing page 

635 

636 :param request: A request object 

637 

638 :returns: tuple of headers, status code, content 

639 """ 

640 

641 fcm = { 

642 'links': [], 

643 'title': l10n.translate( 

644 api.config['metadata']['identification']['title'], 

645 request.locale), 

646 'description': 

647 l10n.translate( 

648 api.config['metadata']['identification']['description'], 

649 request.locale) 

650 } 

651 

652 LOGGER.debug('Creating links') 

653 # TODO: put title text in config or translatable files? 

654 fcm['links'] = [{ 

655 'rel': 'about', 

656 'type': 'text/html', 

657 'title': l10n.translate( 

658 api.config['metadata']['identification']['title'], 

659 request.locale), 

660 'href': api.config['metadata']['identification']['url'] 

661 }, { 

662 'rel': request.get_linkrel(F_JSON), 

663 'type': FORMAT_TYPES[F_JSON], 

664 'title': l10n.translate('This document as JSON', request.locale), 

665 'href': f"{api.base_url}?f={F_JSON}" 

666 }, { 

667 'rel': request.get_linkrel(F_JSONLD), 

668 'type': FORMAT_TYPES[F_JSONLD], 

669 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa 

670 'href': f"{api.base_url}?f={F_JSONLD}" 

671 }, { 

672 'rel': request.get_linkrel(F_HTML), 

673 'type': FORMAT_TYPES[F_HTML], 

674 'title': l10n.translate('This document as HTML', request.locale), 

675 'href': f"{api.base_url}?f={F_HTML}", 

676 'hreflang': api.default_locale 

677 }, { 

678 'rel': 'service-desc', 

679 'type': 'application/vnd.oai.openapi+json;version=3.0', 

680 'title': l10n.translate('The OpenAPI definition as JSON', request.locale), # noqa 

681 'href': f"{api.base_url}/openapi" 

682 }, { 

683 'rel': 'service-doc', 

684 'type': FORMAT_TYPES[F_HTML], 

685 'title': l10n.translate('The OpenAPI definition as HTML', request.locale), # noqa 

686 'href': f"{api.base_url}/openapi?f={F_HTML}", 

687 'hreflang': api.default_locale 

688 }, { 

689 'rel': 'conformance', 

690 'type': FORMAT_TYPES[F_JSON], 

691 'title': l10n.translate('Conformance', request.locale), 

692 'href': f"{api.base_url}/conformance" 

693 }, { 

694 'rel': 'data', 

695 'type': FORMAT_TYPES[F_JSON], 

696 'title': l10n.translate('Collections', request.locale), 

697 'href': api.get_collections_url() 

698 }, { 

699 'rel': f'{OGC_RELTYPES_BASE}/processes', 

700 'type': FORMAT_TYPES[F_JSON], 

701 'title': l10n.translate('Processes', request.locale), 

702 'href': f"{api.base_url}/processes" 

703 }, { 

704 'rel': f'{OGC_RELTYPES_BASE}/job-list', 

705 'type': FORMAT_TYPES[F_JSON], 

706 'title': l10n.translate('Jobs', request.locale), 

707 'href': f"{api.base_url}/jobs" 

708 }, { 

709 'rel': f'{OGC_RELTYPES_BASE}/tiling-schemes', 

710 'type': FORMAT_TYPES[F_JSON], 

711 'title': l10n.translate('The list of supported tiling schemes as JSON', request.locale), # noqa 

712 'href': f"{api.base_url}/TileMatrixSets?f=json" 

713 }, { 

714 'rel': f'{OGC_RELTYPES_BASE}/tiling-schemes', 

715 'type': FORMAT_TYPES[F_HTML], 

716 'title': l10n.translate('The list of supported tiling schemes as HTML', request.locale), # noqa 

717 'href': f"{api.base_url}/TileMatrixSets?f=html" 

718 }] 

719 

720 if api.pubsub_client is not None and not api.pubsub_client.hidden: 720 ↛ 721line 720 didn't jump to line 721 because the condition on line 720 was never true

721 LOGGER.debug('Adding PubSub broker link') 

722 pubsub_link = { 

723 'rel': 'hub', 

724 'type': 'application/json', 

725 'title': l10n.translate('Pub/Sub broker', request.locale), 

726 'href': api.pubsub_client.broker_safe_url 

727 } 

728 if api.pubsub_client.channel is not None: 

729 pubsub_link['channel'] = api.pubsub_client.channel 

730 

731 fcm['links'].append(pubsub_link) 

732 

733 if api.asyncapi: 733 ↛ 734line 733 didn't jump to line 734 because the condition on line 733 was never true

734 fcm['links'].append({ 

735 'rel': 'service-doc', 

736 'type': 'text/html', 

737 'title': l10n.translate('The AsyncAPI definition as HTML', request.locale), # noqa 

738 'href': f'{api.base_url}/asyncapi?f=html' 

739 }) 

740 fcm['links'].append({ 

741 'rel': 'service-desc', 

742 'type': 'application/asyncapi+json', 

743 'title': l10n.translate('The AsyncAPI definition as JSON', request.locale), # noqa 

744 'href': f'{api.base_url}/asyncapi?f=json' 

745 }) 

746 

747 headers = request.get_response_headers(**api.api_headers) 

748 if request.format == F_HTML: # render 

749 

750 for resource_type in ['collection', 'process', 'stac-collection']: 

751 fcm[resource_type] = False 

752 

753 found = filter_dict_by_key_value(api.config['resources'], 

754 'type', resource_type) 

755 if found: 

756 fcm[resource_type] = True 

757 if resource_type == 'collection': # check for tiles 

758 for key, value in found.items(): 

759 if filter_providers_by_type(value['providers'], 759 ↛ 761line 759 didn't jump to line 761 because the condition on line 759 was never true

760 'tile'): 

761 fcm['tile'] = True 

762 

763 if api.pubsub_client is not None and not api.pubsub_client.hidden: 763 ↛ 764line 763 didn't jump to line 764 because the condition on line 763 was never true

764 fcm['pubsub'] = { 

765 'name': api.pubsub_client.name, 

766 'url': api.pubsub_client.broker_safe_url, 

767 'channel': api.pubsub_client.channel, 

768 'asyncapi': api.asyncapi 

769 } 

770 

771 content = render_j2_template( 

772 api.tpl_config, api.config['server']['templates'], 

773 'landing_page.html', fcm, request.locale) 

774 

775 return headers, HTTPStatus.OK, content 

776 

777 if request.format == F_JSONLD: 

778 return headers, HTTPStatus.OK, to_json( 

779 api.fcmld, api.pretty_print) 

780 

781 return headers, HTTPStatus.OK, to_json(fcm, api.pretty_print) 

782 

783 

784def openapi_(api: API, request: APIRequest) -> Tuple[dict, int, str]: 

785 """ 

786 Provide OpenAPI document 

787 

788 :param request: A request object 

789 :param openapi: dict of OpenAPI definition 

790 

791 :returns: tuple of headers, status code, content 

792 """ 

793 headers = request.get_response_headers(**api.api_headers) 

794 

795 if request.format == F_HTML: 795 ↛ 796line 795 didn't jump to line 796 because the condition on line 795 was never true

796 template = 'openapi/swagger.html' 

797 if request._args.get('ui') == 'redoc': 

798 template = 'openapi/redoc.html' 

799 

800 path = f'{api.base_url}/openapi' 

801 data = { 

802 'openapi-document-path': path 

803 } 

804 content = render_j2_template( 

805 api.tpl_config, api.config['server']['templates'], template, data, 

806 request.locale) 

807 

808 return headers, HTTPStatus.OK, content 

809 

810 headers['Content-Type'] = 'application/vnd.oai.openapi+json;version=3.0' # noqa 

811 

812 if isinstance(api.openapi, dict): 812 ↛ 816line 812 didn't jump to line 816 because the condition on line 812 was always true

813 return headers, HTTPStatus.OK, to_json(api.openapi, 

814 api.pretty_print) 

815 else: 

816 return headers, HTTPStatus.OK, api.openapi 

817 

818 

819def asyncapi_(api: API, request: APIRequest) -> Tuple[dict, int, str]: 

820 """ 

821 Provide AsyncAPI document 

822 

823 :param request: A request object 

824 

825 :returns: tuple of headers, status code, content 

826 """ 

827 

828 headers = request.get_response_headers(**api.api_headers) 

829 

830 if not api.asyncapi: 

831 msg = 'AsyncAPI not supported/configured' 

832 return api.get_exception( 

833 HTTPStatus.NOT_IMPLEMENTED, headers, request.format, 

834 'NoApplicableCode', msg) 

835 

836 if request.format == F_HTML: 

837 template = 'asyncapi.html' 

838 

839 path = f'{api.base_url}/asyncapi' 

840 data = { 

841 'asyncapi-document-path': path 

842 } 

843 content = render_j2_template( 

844 api.tpl_config, api.config['server']['templates'], template, data, 

845 request.locale) 

846 

847 return headers, HTTPStatus.OK, content 

848 

849 headers['Content-Type'] = 'application/asyncapi+json' 

850 

851 return headers, HTTPStatus.OK, to_json(api.asyncapi, api.pretty_print) 

852 

853 

854def conformance(api: API, request: APIRequest) -> Tuple[dict, int, str]: 

855 """ 

856 Provide conformance definition 

857 

858 :param request: A request object 

859 

860 :returns: tuple of headers, status code, content 

861 """ 

862 

863 apis_dict = all_apis() 

864 

865 conformance_list = list(CONFORMANCE_CLASSES) 

866 

867 for key, value in api.config['resources'].items(): 

868 if value['type'] == 'process': 

869 conformance_list.extend( 

870 apis_dict['process'].CONFORMANCE_CLASSES) 

871 else: 

872 for provider in value['providers']: 

873 if provider['type'] in apis_dict: 873 ↛ 874line 873 didn't jump to line 874 because the condition on line 873 was never true

874 conformance_list.extend( 

875 apis_dict[provider['type']].CONFORMANCE_CLASSES) 

876 if provider['type'] == 'feature': 876 ↛ 879line 876 didn't jump to line 879 because the condition on line 876 was always true

877 conformance_list.extend( 

878 apis_dict['itemtypes'].CONFORMANCE_CLASSES_FEATURES) # noqa 

879 if provider['type'] == 'record': 879 ↛ 880line 879 didn't jump to line 880 because the condition on line 879 was never true

880 conformance_list.extend( 

881 apis_dict['itemtypes'].CONFORMANCE_CLASSES_RECORDS) 

882 

883 if api.pubsub_client is not None: 883 ↛ 884line 883 didn't jump to line 884 because the condition on line 883 was never true

884 conformance_list.extend(apis_dict['pubsub'].CONFORMANCE_CLASSES) 

885 

886 conformance = { 

887 'conformsTo': sorted(list(set(conformance_list))) 

888 } 

889 

890 headers = request.get_response_headers(**api.api_headers) 

891 if request.format == F_HTML: # render 

892 content = render_j2_template( 

893 api.tpl_config, api.config['server']['templates'], 

894 'conformance.html', conformance, request.locale) 

895 

896 return headers, HTTPStatus.OK, content 

897 

898 return headers, HTTPStatus.OK, to_json(conformance, api.pretty_print) 

899 

900 

901@jsonldify 

902def describe_collections(api: API, request: APIRequest, 

903 dataset: str | None = None) -> Tuple[dict, int, str]: 

904 """ 

905 Provide collection metadata 

906 

907 :param request: A request object 

908 :param dataset: name of collection 

909 

910 :returns: tuple of headers, status code, content 

911 """ 

912 

913 headers = request.get_response_headers(**api.api_headers) 

914 

915 fcm = { 

916 'collections': [], 

917 'links': [] 

918 } 

919 

920 collections = filter_dict_by_key_value(api.config['resources'], 

921 'type', 'collection') 

922 

923 if all([dataset is not None, dataset not in collections.keys()]): 923 ↛ 924line 923 didn't jump to line 924 because the condition on line 923 was never true

924 msg = 'Collection not found' 

925 return api.get_exception( 

926 HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg) 

927 

928 if dataset is not None: 

929 collections_dict = {dataset: api.config['resources'][dataset]} 

930 else: 

931 collections_dict = collections 

932 

933 LOGGER.debug('Creating collections') 

934 for k, v in collections_dict.items(): 

935 if v.get('visibility', 'default') == 'hidden': 935 ↛ 936line 935 didn't jump to line 936 because the condition on line 935 was never true

936 LOGGER.debug(f'Skipping hidden layer: {k}') 

937 continue 

938 

939 try: 

940 fcm['collections'].append( 

941 gen_collection(api, request, k, request.locale)) 

942 except Exception as err: 

943 LOGGER.warning(f'Error generating collection {k}: {err}') 

944 if dataset is None: 

945 LOGGER.debug('Skipping failed dataset') 

946 else: 

947 return api.get_exception( 

948 HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format, 

949 'NoApplicableCode', 'Error generating collection') 

950 

951 if dataset is not None: 

952 fcm = fcm['collections'][0] 

953 

954 if dataset is None: 

955 # TODO: translate 

956 fcm['links'].append({ 

957 'type': FORMAT_TYPES[F_JSON], 

958 'rel': request.get_linkrel(F_JSON), 

959 'title': l10n.translate('This document as JSON', request.locale), # noqa 

960 'href': f'{api.get_collections_url()}?f={F_JSON}' 

961 }) 

962 fcm['links'].append({ 

963 'type': FORMAT_TYPES[F_JSONLD], 

964 'rel': request.get_linkrel(F_JSONLD), 

965 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa 

966 'href': f'{api.get_collections_url()}?f={F_JSONLD}' 

967 }) 

968 fcm['links'].append({ 

969 'type': FORMAT_TYPES[F_HTML], 

970 'rel': request.get_linkrel(F_HTML), 

971 'title': l10n.translate('This document as HTML', request.locale), # noqa 

972 'href': f'{api.get_collections_url()}?f={F_HTML}' 

973 }) 

974 

975 if request.format == F_HTML: # render 

976 fcm['base_url'] = api.base_url 

977 fcm['collections_path'] = api.get_collections_url() 

978 if dataset is not None: 

979 tpl_config = api.get_dataset_templates(dataset) 

980 content = render_j2_template(api.tpl_config, tpl_config, 

981 'collections/collection.html', 

982 fcm, request.locale) 

983 else: 

984 content = render_j2_template( 

985 api.tpl_config, api.config['server']['templates'], 

986 'collections/index.html', fcm, request.locale) 

987 

988 return headers, HTTPStatus.OK, content 

989 

990 if request.format == F_JSONLD: 

991 jsonld = api.fcmld.copy() 

992 if dataset is not None: 

993 jsonld['dataset'] = jsonldify_collection(api, fcm, 

994 request.locale) 

995 else: 

996 jsonld['dataset'] = [ 

997 jsonldify_collection(api, c, request.locale) 

998 for c in fcm.get('collections', []) 

999 ] 

1000 return headers, HTTPStatus.OK, to_json(jsonld, api.pretty_print) 

1001 

1002 return headers, HTTPStatus.OK, to_json(fcm, api.pretty_print) 

1003 

1004 

1005def get_collection_schema(api: API, request: Union[APIRequest, Any], 

1006 dataset: str) -> Tuple[dict, int, str]: 

1007 """ 

1008 Returns a collection schema 

1009 

1010 :param request: A request object 

1011 :param dataset: dataset name 

1012 

1013 :returns: tuple of headers, status code, content 

1014 """ 

1015 

1016 headers = request.get_response_headers(**api.api_headers) 

1017 

1018 if any([dataset is None, 1018 ↛ 1021line 1018 didn't jump to line 1021 because the condition on line 1018 was never true

1019 dataset not in api.config['resources'].keys()]): 

1020 

1021 msg = 'Collection not found' 

1022 return api.get_exception( 

1023 HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg) 

1024 

1025 LOGGER.debug('Creating collection schema') 

1026 try: 

1027 LOGGER.debug('Loading feature provider') 

1028 p = load_plugin('provider', get_provider_by_type( 

1029 api.config['resources'][dataset]['providers'], 'feature')) 

1030 except ProviderTypeError: 

1031 try: 

1032 LOGGER.debug('Loading coverage provider') 

1033 p = load_plugin('provider', get_provider_by_type( 

1034 api.config['resources'][dataset]['providers'], 'coverage')) # noqa 

1035 except ProviderTypeError: 

1036 try: 

1037 LOGGER.debug('Loading record provider') 

1038 p = load_plugin('provider', get_provider_by_type( 

1039 api.config['resources'][dataset]['providers'], 'record')) 

1040 except ProviderTypeError: 

1041 LOGGER.debug('Loading edr provider') 

1042 p = load_plugin('provider', get_provider_by_type( 

1043 api.config['resources'][dataset]['providers'], 'edr')) 

1044 except ProviderGenericError as err: 

1045 LOGGER.error(err) 

1046 return api.get_exception( 

1047 err.http_status_code, headers, request.format, 

1048 err.ogc_exception_code, err.message) 

1049 

1050 schema = { 

1051 'type': 'object', 

1052 'title': l10n.translate( 

1053 api.config['resources'][dataset]['title'], request.locale), 

1054 'properties': {}, 

1055 '$schema': 'http://json-schema.org/draft/2019-09/schema', 

1056 '$id': f'{api.get_collections_url()}/{dataset}/schema' 

1057 } 

1058 

1059 if p.type != 'coverage': 1059 ↛ 1065line 1059 didn't jump to line 1065 because the condition on line 1059 was always true

1060 schema['properties']['geometry'] = { 

1061 'format': 'geometry-any', 

1062 'x-ogc-role': 'primary-geometry' 

1063 } 

1064 

1065 for k, v in p.fields.items(): 

1066 schema['properties'][k] = v 

1067 if v['type'] == 'float': 1067 ↛ 1068line 1067 didn't jump to line 1068 because the condition on line 1067 was never true

1068 schema['properties'][k]['type'] = 'number' 

1069 if v.get('format') is None: 1069 ↛ 1072line 1069 didn't jump to line 1072 because the condition on line 1069 was always true

1070 schema['properties'][k].pop('format', None) 

1071 

1072 if k == p.id_field: 

1073 schema['properties'][k]['x-ogc-role'] = 'id' 

1074 if k == p.time_field: 

1075 schema['properties'][k]['x-ogc-role'] = 'primary-instant' 

1076 

1077 if request.format == F_HTML: # render 

1078 tpl_config = api.get_dataset_templates(dataset) 

1079 schema['title'] = l10n.translate( 

1080 api.config['resources'][dataset]['title'], request.locale) 

1081 

1082 schema['collections_path'] = api.get_collections_url() 

1083 schema['dataset_path'] = f'{api.get_collections_url()}/{dataset}' 

1084 

1085 content = render_j2_template(api.tpl_config, tpl_config, 

1086 'collections/schema.html', 

1087 schema, request.locale) 

1088 

1089 return headers, HTTPStatus.OK, content 

1090 

1091 headers['Content-Type'] = 'application/schema+json' 

1092 

1093 return headers, HTTPStatus.OK, to_json(schema, api.pretty_print) 

1094 

1095 

1096def validate_bbox(value: list | None = None) -> list: 

1097 """ 

1098 Helper function to validate bbox parameter 

1099 

1100 :param value: `list` of minx, miny, maxx, maxy 

1101 

1102 :returns: bbox as `list` of `float` values 

1103 """ 

1104 

1105 if value is None: 1105 ↛ 1106line 1105 didn't jump to line 1106 because the condition on line 1105 was never true

1106 LOGGER.debug('bbox is empty') 

1107 return [] 

1108 

1109 bbox = value.split(',') 

1110 

1111 if len(bbox) not in [4, 6]: 

1112 msg = 'bbox should be either 4 values (minx,miny,maxx,maxy) ' \ 

1113 'or 6 values (minx,miny,minz,maxx,maxy,maxz)' 

1114 LOGGER.debug(msg) 

1115 raise ValueError(msg) 

1116 

1117 try: 

1118 bbox = [float(c) for c in bbox] 

1119 except ValueError as err: 

1120 msg = 'bbox values must be numbers' 

1121 err.args = (msg,) 

1122 LOGGER.debug(msg) 

1123 raise 

1124 

1125 if (len(bbox) == 4 and bbox[1] > bbox[3]) \ 

1126 or (len(bbox) == 6 and bbox[1] > bbox[4]): 

1127 msg = 'miny should be less than maxy' 

1128 LOGGER.debug(msg) 

1129 raise ValueError(msg) 

1130 

1131 if (len(bbox) == 4 and bbox[0] > bbox[2]) \ 

1132 or (len(bbox) == 6 and bbox[0] > bbox[3]): 

1133 msg = 'minx is greater than maxx (possibly antimeridian bbox)' 

1134 LOGGER.debug(msg) 

1135 

1136 if len(bbox) == 6 and bbox[2] > bbox[5]: 

1137 msg = 'minz should be less than maxz' 

1138 LOGGER.debug(msg) 

1139 raise ValueError(msg) 

1140 

1141 return bbox 

1142 

1143 

1144def validate_datetime(resource_def: dict, 

1145 datetime_: str | None = None) -> str: 

1146 """ 

1147 Helper function to validate temporal parameter 

1148 

1149 :param resource_def: `dict` of configuration resource definition 

1150 :param datetime_: `str` of datetime parameter 

1151 

1152 :returns: `str` of datetime input, if valid 

1153 """ 

1154 

1155 # TODO: pass datetime to query as a `datetime` object 

1156 # we would need to ensure partial dates work accordingly 

1157 # as well as setting '..' values to `None` so that underlying 

1158 # providers can just assume a `datetime.datetime` object 

1159 # 

1160 # NOTE: needs testing when passing partials from API to backend 

1161 

1162 datetime_invalid = False 

1163 

1164 if datetime_ is not None and 'temporal' in resource_def: 

1165 

1166 dateparse_begin = partial(dateparse, default=datetime.min) 

1167 dateparse_end = partial(dateparse, default=datetime.max) 

1168 unix_epoch = datetime(1970, 1, 1, 0, 0, 0) 

1169 dateparse_ = partial(dateparse, default=unix_epoch) 

1170 

1171 te = resource_def['temporal'] 

1172 

1173 try: 

1174 if te['begin'] is not None and te['begin'].tzinfo is None: 1174 ↛ 1175line 1174 didn't jump to line 1175 because the condition on line 1174 was never true

1175 te['begin'] = te['begin'].replace(tzinfo=pytz.UTC) 

1176 if te['end'] is not None and te['end'].tzinfo is None: 1176 ↛ 1177line 1176 didn't jump to line 1177 because the condition on line 1176 was never true

1177 te['end'] = te['end'].replace(tzinfo=pytz.UTC) 

1178 except AttributeError: 

1179 msg = 'Configured times should be RFC3339' 

1180 LOGGER.error(msg) 

1181 raise ValueError(msg) 

1182 

1183 if '/' in datetime_: # envelope 

1184 LOGGER.debug('detected time range') 

1185 LOGGER.debug('Validating time windows') 

1186 

1187 # normalize "" to ".." (actually changes datetime_) 

1188 datetime_ = re.sub(r'^/', '../', datetime_) 

1189 datetime_ = re.sub(r'/$', '/..', datetime_) 

1190 

1191 datetime_begin, datetime_end = datetime_.split('/') 

1192 if datetime_begin != '..': 1192 ↛ 1198line 1192 didn't jump to line 1198 because the condition on line 1192 was always true

1193 datetime_begin = dateparse_begin(datetime_begin) 

1194 if datetime_begin.tzinfo is None: 

1195 datetime_begin = datetime_begin.replace( 

1196 tzinfo=pytz.UTC) 

1197 

1198 if datetime_end != '..': 

1199 datetime_end = dateparse_end(datetime_end) 

1200 if datetime_end.tzinfo is None: 

1201 datetime_end = datetime_end.replace(tzinfo=pytz.UTC) 

1202 

1203 datetime_invalid = any([ 

1204 (te['end'] is not None and datetime_begin != '..' and 

1205 datetime_begin > te['end']), 

1206 (te['begin'] is not None and datetime_end != '..' and 

1207 datetime_end < te['begin']) 

1208 ]) 

1209 

1210 else: # time instant 

1211 LOGGER.debug('detected time instant') 

1212 datetime__ = dateparse_(datetime_) 

1213 if datetime__ != '..': 

1214 if datetime__.tzinfo is None: 

1215 datetime__ = datetime__.replace(tzinfo=pytz.UTC) 

1216 datetime_invalid = any([ 

1217 (te['begin'] is not None and datetime__ != '..' and 

1218 datetime__ < te['begin']), 

1219 (te['end'] is not None and datetime__ != '..' and 

1220 datetime__ > te['end']) 

1221 ]) 

1222 

1223 if datetime_invalid: 1223 ↛ 1224line 1223 didn't jump to line 1224 because the condition on line 1223 was never true

1224 msg = 'datetime parameter out of range' 

1225 LOGGER.debug(msg) 

1226 raise ValueError(msg) 

1227 

1228 return datetime_ 

1229 

1230 

1231def validate_subset(value: str) -> dict: 

1232 """ 

1233 Helper function to validate subset parameter 

1234 

1235 :param value: `subset` parameter 

1236 

1237 :returns: dict of axis/values 

1238 """ 

1239 

1240 subsets = {} 

1241 

1242 for s in value.split(','): 

1243 LOGGER.debug(f'Processing subset {s}') 

1244 m = re.search(r'(.*)\((.*)\)', s) 

1245 subset_name, values = m.group(1, 2) 

1246 

1247 if '"' in values: 

1248 LOGGER.debug('Values are strings') 

1249 if values.count('"') % 2 != 0: 

1250 msg = 'Invalid format: subset should be like axis("min"[:"max"])' # noqa 

1251 LOGGER.error(msg) 

1252 raise ValueError(msg) 

1253 try: 

1254 LOGGER.debug('Value is an interval') 

1255 m = re.search(r'"(\S+)":"(\S+)"', values) 

1256 values = list(m.group(1, 2)) 

1257 except AttributeError: 

1258 LOGGER.debug('Value is point') 

1259 m = re.search(r'"(.*)"', values) 

1260 values = [m.group(1)] 

1261 else: 

1262 LOGGER.debug('Values are numbers') 

1263 try: 

1264 LOGGER.debug('Value is an interval') 

1265 m = re.search(r'(\S+):(\S+)', values) 

1266 values = list(m.group(1, 2)) 

1267 except AttributeError: 

1268 LOGGER.debug('Value is point') 

1269 values = [values] 

1270 

1271 subsets[subset_name] = list(map(get_typed_value, values)) 

1272 

1273 return subsets 

1274 

1275 

1276def evaluate_limit(requested: Union[None, int], server_limits: dict, 

1277 collection_limits: dict) -> int: 

1278 """ 

1279 Helper function to evaluate limit parameter 

1280 

1281 :param requested: the limit requested by the client 

1282 :param server_limits: `dict` of server limits 

1283 :param collection_limits: `dict` of collection limits 

1284 

1285 :returns: `int` of evaluated limit 

1286 """ 

1287 

1288 effective_limits = ChainMap(collection_limits, server_limits) 

1289 

1290 default = effective_limits.get('default_items', 10) 

1291 max_ = effective_limits.get('max_items', 10) 

1292 on_exceed = effective_limits.get('on_exceed', 'throttle') 

1293 

1294 LOGGER.debug(f'Requested limit: {requested}') 

1295 LOGGER.debug(f'Default limit: {default}') 

1296 LOGGER.debug(f'Maximum limit: {max_}') 

1297 LOGGER.debug(f'On exceed: {on_exceed}') 

1298 

1299 if requested is None: 

1300 LOGGER.debug('no limit requested; returning default') 

1301 return default 

1302 

1303 if isinstance(requested, int): 1303 ↛ 1304line 1303 didn't jump to line 1304 because the condition on line 1303 was never true

1304 requested2 = requested 

1305 else: 

1306 requested2 = get_typed_value(requested) 

1307 

1308 if not isinstance(requested2, int): 

1309 raise ValueError('limit value should be an integer') 

1310 

1311 if requested2 <= 0: 

1312 raise ValueError('limit value should be strictly positive') 

1313 elif requested2 > max_ and on_exceed == 'error': 1313 ↛ 1314line 1313 didn't jump to line 1314 because the condition on line 1313 was never true

1314 raise ValueError(f'Limit of {max_} exceeded') 

1315 else: 

1316 LOGGER.debug('limit requested') 

1317 return min(requested2, max_)