Skip to content

API reference

Generated from the source. The docstrings carry the reasoning rather than restating the signatures, so this is the shortest path to why a piece behaves the way it does.

Building pygeoapi programmatically

app.pygeoapi.factory

Programmatic construction of the pygeoapi sub-app.

Replaces the import of pygeoapi.starlette_app (module globals built from env+file at import time) with explicit construction from dicts. The execute shim and the route table are adapted from pygeoapi/starlette_app.py (MIT); the parity test tests/test_pygeoapi_route_parity.py keeps them aligned to upstream.

CLIENT_CLOSED_REQUEST = 499 module-attribute

Nginx's status for a request whose client left; no client is there to read it.

ConfigValidationError

Bases: Exception

A configuration that does not match pygeoapi's schema or fastgeoapi's own keys.

Source code in app/pygeoapi/factory.py
class ConfigValidationError(Exception):
    """A configuration that does not match pygeoapi's schema or fastgeoapi's own keys."""

normalize_config(config)

Validate the configuration, then fill the keys the runtime assumes.

Mutates and returns the given dict: callers (including the reload path) share one config object, and pygeoapi copies it internally anyway. Only absent keys are filled — a tenant's own values stay.

Validation lives here rather than in the loader because loading a document and vouching for it are different jobs: the loader is asked for partial documents on purpose. This function is the boundary where a document becomes a configuration something is built from, and both build_openapi and build_api come through it, so boot, reload and CLI are covered without anyone having to remember.

It validates the effective configuration — after ${VAR} interpolation. The source document is a different thing: port: ${PORT} is a string where the schema wants an integer, and rejecting it would be rejecting a perfectly good deployment.

Source code in app/pygeoapi/factory.py
def normalize_config(config: dict) -> dict:
    """Validate the configuration, then fill the keys the runtime assumes.

    Mutates and returns the given dict: callers (including the reload
    path) share one config object, and pygeoapi copies it internally
    anyway. Only absent keys are filled — a tenant's own values stay.

    Validation lives here rather than in the loader because loading a
    document and vouching for it are different jobs: the loader is asked
    for partial documents on purpose. This function is the boundary
    where a document becomes a configuration something is built from,
    and both `build_openapi` and `build_api` come through it, so boot,
    reload and CLI are covered without anyone having to remember.

    It validates the **effective** configuration — after `${VAR}`
    interpolation. The source document is a different thing: `port:
    ${PORT}` is a string where the schema wants an integer, and
    rejecting it would be rejecting a perfectly good deployment.
    """
    server = config.setdefault("server", {})
    # `server.bind` is required by pygeoapi's schema and read by nobody
    # here: upstream uses it in its own runners (`starlette_app.py`,
    # `flask_app.py`, `django_app.py`) to bind a socket it opens itself.
    # fastgeoapi's host and port come from `fastgeoapi run` and uvicorn.
    # Refusing to start over it would be validation harming the people
    # it protects — a working deployment stopped by a key that changes
    # nothing — so it is filled with what is actually bound, taken from
    # the same environment `main` exports before loading.
    bind = server.setdefault("bind", {})
    # ruff: ignore[hardcoded-bind-all-interfaces]
    bind.setdefault("host", os.environ.get("HOST", "0.0.0.0"))  # nosec B104
    bind.setdefault("port", int(os.environ.get("PORT", "5000")))

    _validate(config)
    problem = extension_problem(config)
    if problem is not None:
        where, message = problem
        raise ConfigValidationError(f"invalid fastgeoapi configuration at {where}: {message}")
    limits = server.setdefault("limits", {})
    for key, value in _LIMIT_DEFAULTS.items():
        limits.setdefault(key, value)
    for resource in config.get("resources", {}).values():
        _default_to_the_items(resource.get("providers") or [])
    return config

build_openapi(config)

Generate the OpenAPI in memory, with the fastgeoapi fixes at the source.

The document goes through the openapi-pydantic model once: validated, corrected, dumped.

Source code in app/pygeoapi/factory.py
def build_openapi(config: dict) -> dict:
    """Generate the OpenAPI in memory, with the fastgeoapi fixes at the source.

    The document goes through the openapi-pydantic model once: validated,
    corrected, dumped.
    """
    generated = get_oas(normalize_config(config))
    openapi = OpenAPI.model_validate(generated)
    fix_queryables_response_schema(openapi)
    fix_conformance_and_collections_responses(openapi)
    drop_unfiltered_cql2_operations(openapi, config)
    fix_tileset_list_response(openapi)
    describe_tilesets(openapi)
    fix_map_operations(openapi, config)
    drop_unused_tags(openapi)
    return dump_openapi(openapi, like=generated)

build_api(config, openapi)

pygeoapi's API instance, with no file nor env var in between.

Also drops pygeoapi's translated-config cache. l10n.translate_struct memoises the config it hands to the HTML templates in a MODULE-level dict keyed by locale, so after a reload swap the browser would keep seeing config-derived values (the site title, the item-limit options) from the previous config while JSON already served the new one. A new API instance means a new config, so the cache must go with it.

Source code in app/pygeoapi/factory.py
def build_api(config: dict, openapi: dict) -> API:
    """pygeoapi's API instance, with no file nor env var in between.

    Also drops pygeoapi's translated-config cache. ``l10n.translate_struct``
    memoises the config it hands to the HTML templates in a MODULE-level
    dict keyed by locale, so after a reload swap the browser would keep
    seeing config-derived values (the site title, the item-limit options)
    from the previous config while JSON already served the new one. A new
    API instance means a new config, so the cache must go with it.
    """
    from pygeoapi import l10n

    l10n._cfg_cache.clear()
    # Cached plugin instances were built from the previous config: a
    # reload must not keep serving providers that point at data the new
    # config no longer describes.
    invalidate_plugin_cache()
    return API(normalize_config(config), openapi)

execute(api, api_function, request, *args, skip_valid_check=False) async

Adaptation of execute_from_starlette with an explicit API instance.

Source code in app/pygeoapi/factory.py
async def execute(
    api: API,
    api_function: Callable,
    request: Request,
    *args,
    skip_valid_check: bool = False,
) -> Response:
    """Adaptation of ``execute_from_starlette`` with an explicit API instance."""
    api_request = await APIRequest.from_starlette(request, api.locales)
    if not skip_valid_check and not api_request.is_valid():
        headers, status, content = api.get_format_exception(api_request)
    else:
        loop = asyncio.get_running_loop()
        headers, status, content = await loop.run_in_executor(
            None, _call_threadsafe, loop, api_function, api, api_request, *args
        )
        if status != HTTPStatus.NO_CONTENT:
            content = apply_gzip(headers, content)
    return _to_response(headers, status, content)

build_routes(api, specs=None, http_cache=None)

The route table, closed over the api instance.

Adapted 1:1 from pygeoapi/starlette_app.py (admin excluded: in its place fastgeoapi has the reload webhook). Every entry is tagged with its spec group (ADR-0005): specs=None returns the COMPLETE table — the coverage contract the parity test checks against upstream — while the sub-app mounts only the active groups. http_cache gives the map and tile routes their cache headers; None sends none.

Source code in app/pygeoapi/factory.py
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
def build_routes(
    api: API, specs: frozenset[str] | None = None, http_cache: HttpCache | None = None
) -> list[Route]:
    """The route table, closed over the ``api`` instance.

    Adapted 1:1 from ``pygeoapi/starlette_app.py`` (admin excluded: in
    its place fastgeoapi has the reload webhook). Every entry is tagged
    with its spec group (ADR-0005): ``specs=None`` returns the COMPLETE
    table — the coverage contract the parity test checks against
    upstream — while the sub-app mounts only the active groups.
    ``http_cache`` gives the map and tile routes their cache headers;
    None sends none.
    """

    async def landing_page(request: Request) -> Response:
        return await execute(api, core_api.landing_page, request)

    async def openapi_(request: Request) -> Response:
        return await execute(api, core_api.openapi_, request)

    async def asyncapi_(request: Request) -> Response:
        return await execute(api, core_api.asyncapi_, request)

    async def conformance(request: Request) -> Response:
        # fastgeoapi patch: filter the classes by the configured providers.
        from app.pygeoapi.api.conformance import conformance as conformance_fn

        return await execute(api, conformance_fn, request)

    async def tilematrixset(request: Request) -> Response:
        return await execute(
            api,
            tiles_api.tilematrixset,
            request,
            _path_param(request, "tileMatrixSetId"),
        )

    async def tilematrixsets(request: Request) -> Response:
        return await execute(api, tiles_api.tilematrixsets, request)

    async def collection_schema(request: Request) -> Response:
        return await execute(
            api,
            core_api.get_collection_schema,
            request,
            _path_param(request, "collection_id"),
        )

    async def collection_queryables(request: Request) -> Response:
        return await execute(
            api,
            itemtypes_api.get_collection_queryables,
            request,
            _path_param(request, "collection_id"),
        )

    async def collection_tiles(request: Request) -> Response:
        return await execute(
            api,
            tiles_api.get_collection_tiles,
            request,
            _path_param(request, "collection_id"),
        )

    async def collection_tiles_metadata(request: Request) -> Response:
        return await execute(
            api,
            _collection_tiles_metadata,
            request,
            _path_param(request, "collection_id"),
            _path_param(request, "tileMatrixSetId"),
            skip_valid_check=True,
        )

    # ADR-0010: which collections have a natively async tile provider.
    # Probing instantiates the provider, so it runs off the loop once per
    # configured collection; the dict lives with this table, which the
    # sub-app rebuilds on every reload, and unknown names are not kept.
    natives: dict[str, tuple | None] = {}

    async def collection_items_tiles(request: Request) -> Response:
        args = (
            _path_param(request, "collection_id"),
            _path_param(request, "tileMatrixSetId"),
            _path_param(request, "tile_matrix"),
            _path_param(request, "tileRow"),
            _path_param(request, "tileCol"),
        )
        dataset = args[0]
        if dataset in natives:
            probe = natives[dataset]
        else:
            probe = await asyncio.to_thread(async_tiles.native_tile_provider, api, dataset)
            if dataset in api.config["resources"]:
                natives[dataset] = probe
        if probe is not None:
            # A native provider: awaited on the loop, no thread involved.
            api_request = await APIRequest.from_starlette(request, api.locales)
            headers, status, content = await async_tiles.get_collection_tiles_data(
                api, api_request, *args, *probe, http_cache=http_cache
            )
            if status not in _BODILESS:
                content = _gzip_without_timestamp(headers, content)
            return _to_response(headers, status, content)
        return await execute(
            api,
            tiles_api.get_collection_tiles_data,
            request,
            *args,
            skip_valid_check=True,
        )

    async def collection_items(request: Request) -> Response:
        collection_id = _path_param(request, "collection_id")
        item_id = _path_param(request, "item_id")
        if item_id is None:
            if request.method == "POST":
                if request.headers.get("content-type") == "application/geo+json":
                    return await execute(
                        api,
                        itemtypes_api.manage_collection_item,
                        request,
                        "create",
                        collection_id,
                        skip_valid_check=True,
                    )
                return await execute(
                    api,
                    itemtypes_api.get_collection_items,
                    request,
                    collection_id,
                    skip_valid_check=True,
                )
            if request.method == "OPTIONS":
                return await execute(
                    api,
                    itemtypes_api.manage_collection_item,
                    request,
                    "options",
                    collection_id,
                    skip_valid_check=True,
                )
            return await execute(
                api,
                itemtypes_api.get_collection_items,
                request,
                collection_id,
                skip_valid_check=True,
            )
        if request.method in ("DELETE", "PUT", "OPTIONS"):
            action = {
                "DELETE": "delete",
                "PUT": "update",
                "OPTIONS": "options",
            }[request.method]
            return await execute(
                api,
                itemtypes_api.manage_collection_item,
                request,
                action,
                collection_id,
                item_id,
                skip_valid_check=True,
            )
        return await execute(
            api,
            itemtypes_api.get_collection_item,
            request,
            collection_id,
            item_id,
        )

    async def collection_coverage(request: Request) -> Response:
        return await execute(
            api,
            coverages_api.get_collection_coverage,
            request,
            _path_param(request, "collection_id"),
            skip_valid_check=True,
        )

    # Which collections have a natively async map provider, probed once
    # per collection like the tile providers above.
    map_natives: dict[str, tuple | None] = {}

    async def collection_map(request: Request) -> Response:
        dataset = _path_param(request, "collection_id")
        style = _path_param(request, "style_id")
        if dataset in map_natives:
            probe = map_natives[dataset]
        else:
            probe = await asyncio.to_thread(async_maps.native_map_provider, api, dataset)
            if dataset in api.config["resources"]:
                map_natives[dataset] = probe
        if probe is None:
            return await execute(api, maps_api.get_collection_map, request, dataset, style)
        api_request = await APIRequest.from_starlette(request, api.locales)
        if not api_request.is_valid():
            headers, status, content = api.get_format_exception(api_request)
        else:
            drawn = await _unless_disconnected(
                request,
                async_maps.get_collection_map(
                    api, api_request, dataset, style, *probe, http_cache=http_cache
                ),
            )
            if drawn is None:
                return Response(status_code=CLIENT_CLOSED_REQUEST)
            headers, status, content = drawn
        if status not in _BODILESS:
            content = _gzip_without_timestamp(headers, content)
        return _to_response(headers, status, content)

    async def get_processes(request: Request) -> Response:
        return await execute(
            api,
            processes_api.describe_processes,
            request,
            _path_param(request, "process_id"),
        )

    async def get_jobs(request: Request) -> Response:
        job_id = _path_param(request, "job_id")
        if job_id is None:
            return await execute(api, processes_api.get_jobs, request)
        if request.method == "DELETE":
            return await execute(api, processes_api.delete_job, request, job_id)
        return await execute(api, processes_api.get_jobs, request, job_id)

    async def execute_process_jobs(request: Request) -> Response:
        return await execute(
            api,
            processes_api.execute_process,
            request,
            _path_param(request, "process_id"),
        )

    async def get_job_result(request: Request) -> Response:
        # fastgeoapi patch: job response corrected downstream.
        from app.pygeoapi.api.processes import patch_response

        response = await execute(
            api,
            processes_api.get_job_result,
            request,
            _path_param(request, "job_id"),
        )
        return patch_response(response=response)

    async def edr_query(request: Request) -> Response:
        collection_id = _path_param(request, "collection_id")
        instance_id = _path_param(request, "instance_id")
        if collection_id and "/instances/" in collection_id:
            collection_id, _, instance_id = collection_id.partition("/instances/")
        if request.url.path.endswith("instances") or (
            instance_id is not None and request.url.path.endswith(instance_id)
        ):
            return await execute(
                api,
                edr_api.get_collection_edr_instances,
                request,
                collection_id,
                instance_id,
            )
        location_id = _path_param(request, "location_id")
        query_type = "locations" if location_id is not None else request["path"].split("/")[-1]
        return await execute(
            api,
            edr_api.get_collection_edr_query,
            request,
            collection_id,
            instance_id,
            query_type,
            location_id,
            skip_valid_check=True,
        )

    async def collections(request: Request) -> Response:
        # fastgeoapi patch: the items described from the provider that serves them.
        return await execute(
            api,
            describe_collections,
            request,
            _path_param(request, "collection_id"),
        )

    async def stac_catalog_root(request: Request) -> Response:
        return await execute(api, stac_api.get_stac_root, request)

    async def stac_catalog_path(request: Request) -> Response:
        return await execute(api, stac_api.get_stac_path, request, _path_param(request, "path"))

    async def stac_landing_page(request: Request) -> Response:
        return await execute(api, stac_api.landing_page, request)

    async def stac_search(request: Request) -> Response:
        return await execute(api, stac_api.search, request)

    table: list[tuple[str, Route]] = [
        ("core", Route("/", landing_page)),
        ("core", Route("/openapi", openapi_)),
        ("core", Route("/asyncapi", asyncapi_)),
        ("core", Route("/conformance", conformance)),
        ("tiles", Route("/TileMatrixSets/{tileMatrixSetId}", tilematrixset)),
        ("tiles", Route("/TileMatrixSets", tilematrixsets)),
        (
            "core",
            Route("/collections/{collection_id:path}/schema", collection_schema),
        ),
        (
            "core",
            Route(
                "/collections/{collection_id:path}/queryables",
                collection_queryables,
            ),
        ),
        (
            "tiles",
            Route("/collections/{collection_id:path}/tiles", collection_tiles),
        ),
        (
            "tiles",
            Route(
                "/collections/{collection_id:path}/tiles/{tileMatrixSetId}",
                collection_tiles_metadata,
            ),
        ),
        (
            "tiles",
            Route(
                "/collections/{collection_id:path}/tiles/{tileMatrixSetId}/metadata",
                collection_tiles_metadata,
            ),
        ),
        (
            "tiles",
            Route(
                "/collections/{collection_id:path}/tiles/{tileMatrixSetId}/{tile_matrix}/{tileRow}/{tileCol}",
                collection_items_tiles,
            ),
        ),
        (
            "features",
            Route(
                "/collections/{collection_id:path}/items",
                collection_items,
                methods=["GET", "POST", "OPTIONS"],
            ),
        ),
        (
            "features",
            Route(
                "/collections/{collection_id:path}/items/{item_id:path}",
                collection_items,
                methods=["GET", "PUT", "DELETE", "OPTIONS"],
            ),
        ),
        (
            "coverages",
            Route(
                "/collections/{collection_id:path}/coverage",
                collection_coverage,
            ),
        ),
        # The styled path comes first: `{collection_id:path}` is greedy, and
        # after the plain `/map` it would swallow `roads/styles/night` as a
        # collection id. pygeoapi lists them the other way round
        # (`starlette_app.py:733-734`, 0.24), so in pygeoapi no styled map
        # is ever reached.
        (
            "maps",
            Route(
                "/collections/{collection_id:path}/styles/{style_id:path}/map",
                collection_map,
            ),
        ),
        (
            "maps",
            Route("/collections/{collection_id:path}/map", collection_map),
        ),
        ("processes", Route("/processes", get_processes)),
        ("processes", Route("/processes/{process_id}", get_processes)),
        ("processes", Route("/jobs", get_jobs)),
        (
            "processes",
            Route("/jobs/{job_id}", get_jobs, methods=["GET", "DELETE"]),
        ),
        (
            "processes",
            Route(
                "/processes/{process_id}/execution",
                execute_process_jobs,
                methods=["POST"],
            ),
        ),
        ("processes", Route("/jobs/{job_id}/results", get_job_result)),
        ("edr", Route("/collections/{collection_id:path}/position", edr_query)),
        ("edr", Route("/collections/{collection_id:path}/area", edr_query)),
        ("edr", Route("/collections/{collection_id:path}/cube", edr_query)),
        ("edr", Route("/collections/{collection_id:path}/radius", edr_query)),
        (
            "edr",
            Route("/collections/{collection_id:path}/trajectory", edr_query),
        ),
        ("edr", Route("/collections/{collection_id:path}/corridor", edr_query)),
        (
            "edr",
            Route("/collections/{collection_id:path}/locations", edr_query),
        ),
        (
            "edr",
            Route(
                "/collections/{collection_id:path}/locations/{location_id}",
                edr_query,
            ),
        ),
        (
            "edr",
            Route("/collections/{collection_id:path}/instances", edr_query),
        ),
        (
            "edr",
            Route(
                "/collections/{collection_id:path}/instances/{instance_id}",
                edr_query,
            ),
        ),
        (
            "edr",
            Route(
                "/collections/{collection_id:path}/instances/{instance_id}/position",
                edr_query,
            ),
        ),
        (
            "edr",
            Route(
                "/collections/{collection_id:path}/instances/{instance_id}/area",
                edr_query,
            ),
        ),
        (
            "edr",
            Route(
                "/collections/{collection_id:path}/instances/{instance_id}/cube",
                edr_query,
            ),
        ),
        (
            "edr",
            Route(
                "/collections/{collection_id:path}/instances/{instance_id}/radius",
                edr_query,
            ),
        ),
        (
            "edr",
            Route(
                "/collections/{collection_id:path}/instances/{instance_id}/trajectory",
                edr_query,
            ),
        ),
        (
            "edr",
            Route(
                "/collections/{collection_id:path}/instances/{instance_id}/corridor",
                edr_query,
            ),
        ),
        (
            "edr",
            Route(
                "/collections/{collection_id:path}/instances/{instance_id}/locations",
                edr_query,
            ),
        ),
        (
            "edr",
            Route(
                "/collections/{collection_id:path}/instances/{instance_id}/locations/{location_id}",
                edr_query,
            ),
        ),
        ("core", Route("/collections", collections)),
        ("core", Route("/collections/{collection_id:path}", collections)),
        ("stac", Route("/stac", stac_catalog_root)),
        ("stac", Route("/stac/{path:path}", stac_catalog_path)),
        ("stac", Route("/stac-api", stac_landing_page)),
        (
            "stac",
            Route("/stac-api/search", stac_search, methods=["GET", "POST"]),
        ),
    ]
    if specs is None:
        return [route for _, route in table]
    return [route for spec, route in table if spec in specs]

build_pygeoapi_subapp(config, openapi, *, http_cache=None, pages=None)

Complete Starlette sub-app, same shape as the former APP import.

Mounts only the spec groups the config activates (ADR-0005): the reload webhook rebuilds the sub-app, so the mounted set follows every config update. http_cache defaults to the policy of fastgeoapi's settings. pages serves fastgeoapi's own HTML pages and their compiled assets under /_html; None takes the choice of the settings, which keep pygeoapi's pages by default.

Source code in app/pygeoapi/factory.py
def build_pygeoapi_subapp(
    config: dict,
    openapi: dict,
    *,
    http_cache: HttpCache | None = None,
    pages: NativePages | None = None,
) -> Starlette:
    """Complete Starlette sub-app, same shape as the former APP import.

    Mounts only the spec groups the config activates (ADR-0005): the
    reload webhook rebuilds the sub-app, so the mounted set follows
    every config update. ``http_cache`` defaults to the policy of
    fastgeoapi's settings. ``pages`` serves fastgeoapi's own HTML pages
    and their compiled assets under ``/_html``; None takes the choice of
    the settings, which keep pygeoapi's pages by default.
    """
    from app.html.activation import default_pages
    from app.pygeoapi.registry import active_specs

    api = build_api(config, openapi)
    static_dir = Path(pygeoapi.__file__).parent / "static"
    try:
        static_dir = Path(config["server"]["templates"]["static"])
    except KeyError:
        pass
    url_prefix = get_api_rules(config).get_url_prefix("starlette")
    routes = build_routes(
        api,
        specs=active_specs(config),
        http_cache=http_cache if http_cache is not None else default_http_cache(),
    )
    mounts = [Mount("/static", StaticFiles(directory=static_dir))]
    if pages is None:
        pages = default_pages()
    if pages is not None:
        routes = pages.wrap(api, routes)
        if pages.static.is_dir():
            mounts.append(Mount("/_html", app=ImmutableFiles(directory=pages.static)))
    return Starlette(routes=[*mounts, Mount(url_prefix or "/", routes=routes)])

The storage layer

app.provider.storage.factory

Store factory: from a source reference to the right backend.

load_store is the factory (in the spirit of pygeoapi's load_plugin): it maps a base — an s3:///gs:///az:/// file:// URL or a local directory — to the right backend. Credentials come ONLY from each cloud's standard environment variables (for Tigris: AWS_ENDPOINT_URL_S3 from the Fly secrets).

split_source(source)

Split an object reference into (base, relative key).

Source code in app/provider/storage/factory.py
def split_source(source: str) -> tuple[str, str]:
    """Split an object reference into (base, relative key)."""
    if source.startswith(_URL_SCHEMES):
        base, _, key = source.rpartition("/")
        return f"{base}/", key
    path = Path(source)
    if not path.is_absolute():
        path = Path.cwd() / path
    return str(path.parent), path.name

is_remote(source)

Whether source names an object across a network: a bucket key or an http(s) URL.

Source code in app/provider/storage/factory.py
def is_remote(source: str) -> bool:
    """Whether ``source`` names an object across a network: a bucket key or an http(s) URL."""
    return source.startswith(_URL_SCHEMES) and not source.startswith("file://")

obstore_options(store_options, base='')

store_options spelled for obstore, as :func:load_store builds a store with them.

Source code in app/provider/storage/factory.py
def obstore_options(store_options: dict | None, base: str = "") -> dict:
    """``store_options`` spelled for obstore, as :func:`load_store` builds a store with them."""
    return _for_obstore(dict(store_options or {}), base)

load_store(base, store_options=None)

Build the backend for a base URL/directory.

store_options are the cloud store settings — region, skip_signature for public data, endpoint for an S3-compatible service (and an explicit one wins over the process environment). They are meaningless for a local path and ignored there.

Source code in app/provider/storage/factory.py
def load_store(base: str, store_options: dict | None = None) -> ObjectStore:
    """Build the backend for a base URL/directory.

    ``store_options`` are the cloud store settings — ``region``,
    ``skip_signature`` for public data, ``endpoint`` for an
    S3-compatible service (and an explicit one wins over the process
    environment). They are meaningless for a local path and ignored
    there.
    """
    if base.startswith(_URL_SCHEMES):
        from obstore.store import from_url

        if base.startswith(("http://", "https://")):
            # The HTTP store adds its own slash before the key: a base ending in
            # one asks for `prefix//key`, which some servers answer with 404.
            base = base.rstrip("/")
        if store_options:
            config = _for_obstore(store_options, base)
            with _explicit_endpoint_wins(config):
                # ty: `from_url` is overloaded per provider-specific config
                # type, and ours is a plain mapping read from the tenant's
                # configuration — the value is only known at runtime.
                return ObstoreStore(
                    from_url(base, config=config)  # ty: ignore[no-matching-overload]
                )
        return ObstoreStore(from_url(base))
    from obstore.store import LocalStore

    return ObstoreStore(LocalStore(base))

app.provider.storage.ranges

Byte ranges of one object: the seam between a format's parser and the storage layer.

A format read by ranges (PMTiles, COG, FlatGeobuf) only needs "these bytes at this offset". :class:ByteRanges is that contract, with a sync and an async side; :class:ObjectRanges binds it to one key of an :class:ObjectStore, so the parser never learns about buckets.

ByteRanges

Bases: Protocol

Ranged reads over one object, sync and async.

Source code in app/provider/storage/ranges.py
@runtime_checkable
class ByteRanges(Protocol):
    """Ranged reads over one object, sync and async."""

    def read(self, offset: int, length: int) -> bytes:
        """``length`` bytes from ``offset``."""
        ...

    async def aread(self, offset: int, length: int) -> bytes:
        """Async twin of :meth:`read`."""
        ...

    def read_many(self, ranges: Sequence[tuple[int, int]]) -> list[bytes]:
        """Several ``(offset, length)`` ranges, in request order."""
        ...

    async def aread_many(self, ranges: Sequence[tuple[int, int]]) -> list[bytes]:
        """Async twin of :meth:`read_many`."""
        ...

read(offset, length)

length bytes from offset.

Source code in app/provider/storage/ranges.py
def read(self, offset: int, length: int) -> bytes:
    """``length`` bytes from ``offset``."""
    ...

aread(offset, length) async

Async twin of :meth:read.

Source code in app/provider/storage/ranges.py
async def aread(self, offset: int, length: int) -> bytes:
    """Async twin of :meth:`read`."""
    ...

read_many(ranges)

Several (offset, length) ranges, in request order.

Source code in app/provider/storage/ranges.py
def read_many(self, ranges: Sequence[tuple[int, int]]) -> list[bytes]:
    """Several ``(offset, length)`` ranges, in request order."""
    ...

aread_many(ranges) async

Async twin of :meth:read_many.

Source code in app/provider/storage/ranges.py
async def aread_many(self, ranges: Sequence[tuple[int, int]]) -> list[bytes]:
    """Async twin of :meth:`read_many`."""
    ...

ObjectRanges

:class:ByteRanges over key in store.

Source code in app/provider/storage/ranges.py
class ObjectRanges:
    """:class:`ByteRanges` over ``key`` in ``store``."""

    def __init__(self, store: ObjectStore, key: str) -> None:
        self._store = store
        self._key = key

    def read(self, offset: int, length: int) -> bytes:
        """``length`` bytes from ``offset``."""
        return self._store.get_range(self._key, offset, length)

    async def aread(self, offset: int, length: int) -> bytes:
        """Async twin of :meth:`read`."""
        return await self._store.aget_range(self._key, offset, length)

    def read_many(self, ranges: Sequence[tuple[int, int]]) -> list[bytes]:
        """Several ranges, in request order."""
        return self._store.get_ranges(self._key, ranges)

    async def aread_many(self, ranges: Sequence[tuple[int, int]]) -> list[bytes]:
        """Async twin of :meth:`read_many`."""
        return await self._store.aget_ranges(self._key, ranges)

read(offset, length)

length bytes from offset.

Source code in app/provider/storage/ranges.py
def read(self, offset: int, length: int) -> bytes:
    """``length`` bytes from ``offset``."""
    return self._store.get_range(self._key, offset, length)

aread(offset, length) async

Async twin of :meth:read.

Source code in app/provider/storage/ranges.py
async def aread(self, offset: int, length: int) -> bytes:
    """Async twin of :meth:`read`."""
    return await self._store.aget_range(self._key, offset, length)

read_many(ranges)

Several ranges, in request order.

Source code in app/provider/storage/ranges.py
def read_many(self, ranges: Sequence[tuple[int, int]]) -> list[bytes]:
    """Several ranges, in request order."""
    return self._store.get_ranges(self._key, ranges)

aread_many(ranges) async

Async twin of :meth:read_many.

Source code in app/provider/storage/ranges.py
async def aread_many(self, ranges: Sequence[tuple[int, int]]) -> list[bytes]:
    """Async twin of :meth:`read_many`."""
    return await self._store.aget_ranges(self._key, ranges)

SingleFlightRanges

A :class:ByteRanges wrapper: identical awaited reads in flight share one fetch.

Fifty tile requests arriving together on a cold provider each want the same header, root and leaf directories; without this they read them fifty times (measured on Overture: 245 ranged reads for 50 tiles instead of 58). This is de-duplication of what is in flight, not a cache: a later identical read fetches again. Flights are kept per event loop; the synchronous side passes straight through.

Source code in app/provider/storage/ranges.py
class SingleFlightRanges:
    """A :class:`ByteRanges` wrapper: identical awaited reads in flight share one fetch.

    Fifty tile requests arriving together on a cold provider each want
    the same header, root and leaf directories; without this they read
    them fifty times (measured on Overture: 245 ranged reads for 50
    tiles instead of 58). This is de-duplication of what is in flight,
    not a cache: a later identical read fetches again. Flights are kept
    per event loop; the synchronous side passes straight through.
    """

    def __init__(self, inner: ByteRanges) -> None:
        self.inner = inner
        self._flights: dict[int, dict[tuple[int, int], asyncio.Future[bytes]]] = {}

    def read(self, offset: int, length: int) -> bytes:
        """Sync read, untouched."""
        return self.inner.read(offset, length)

    def read_many(self, ranges: Sequence[tuple[int, int]]) -> list[bytes]:
        """Sync ranged reads, untouched."""
        return self.inner.read_many(ranges)

    async def aread(self, offset: int, length: int) -> bytes:
        """Await the fetch already in flight for this range, or start it."""
        loop = asyncio.get_running_loop()
        flights = self._flights.setdefault(id(loop), {})
        key = (offset, length)
        flight = flights.get(key)
        if flight is None:
            flight = loop.create_task(self.inner.aread(offset, length))
            flights[key] = flight
            flight.add_done_callback(lambda _done: flights.pop(key, None))
        return await flight

    async def aread_many(self, ranges: Sequence[tuple[int, int]]) -> list[bytes]:
        """Async ranged reads, untouched: batches are unique by construction."""
        return await self.inner.aread_many(ranges)

read(offset, length)

Sync read, untouched.

Source code in app/provider/storage/ranges.py
def read(self, offset: int, length: int) -> bytes:
    """Sync read, untouched."""
    return self.inner.read(offset, length)

read_many(ranges)

Sync ranged reads, untouched.

Source code in app/provider/storage/ranges.py
def read_many(self, ranges: Sequence[tuple[int, int]]) -> list[bytes]:
    """Sync ranged reads, untouched."""
    return self.inner.read_many(ranges)

aread(offset, length) async

Await the fetch already in flight for this range, or start it.

Source code in app/provider/storage/ranges.py
async def aread(self, offset: int, length: int) -> bytes:
    """Await the fetch already in flight for this range, or start it."""
    loop = asyncio.get_running_loop()
    flights = self._flights.setdefault(id(loop), {})
    key = (offset, length)
    flight = flights.get(key)
    if flight is None:
        flight = loop.create_task(self.inner.aread(offset, length))
        flights[key] = flight
        flight.add_done_callback(lambda _done: flights.pop(key, None))
    return await flight

aread_many(ranges) async

Async ranged reads, untouched: batches are unique by construction.

Source code in app/provider/storage/ranges.py
async def aread_many(self, ranges: Sequence[tuple[int, int]]) -> list[bytes]:
    """Async ranged reads, untouched: batches are unique by construction."""
    return await self.inner.aread_many(ranges)

Providers with two faces

app.provider.base

The async surface fastgeoapi adds beside pygeoapi's provider contract (ADR-0010).

pygeoapi's contract is synchronous and lives in its root classes (BaseProvider, BaseTileProvider): the API layer reads attributes off the instance and calls the sync methods from a threadpool. This module does not import pygeoapi. It adds the async part only, and the concrete provider pairs the two::

class TilesProvider(AsyncProviderMixin, StorageBackedMixin, BaseTileProvider):
    native_async = True

The mixin must come FIRST in the bases: pygeoapi's roots never call super().__init__, so placed after them its __init__ would not run and provider_def would never be captured.

AsyncProviderMixin

The async part, and nothing else.

native_async is a declaration, never an inspection: a provider that awaits its I/O for real sets it to True and writes the async twins (a + the sync method's name: aget_tiles, aquery). Everyone else keeps the default and is served through a thread by :func:async_view.

Source code in app/provider/base.py
class AsyncProviderMixin:
    """The async part, and nothing else.

    ``native_async`` is a declaration, never an inspection: a provider
    that awaits its I/O for real sets it to ``True`` and writes the async
    twins (``a`` + the sync method's name: ``aget_tiles``, ``aquery``).
    Everyone else keeps the default and is served through a thread by
    :func:`async_view`.
    """

    native_async: ClassVar[bool] = False

    def __init__(self, provider_def: dict, *args: Any, **kwargs: Any) -> None:
        self.provider_def = provider_def
        # Cooperative by design: the next class in the MRO is the pygeoapi
        # root, which takes `provider_def`. ty only sees `object` here.
        super().__init__(provider_def, *args, **kwargs)  # ty: ignore[too-many-positional-arguments]

    async def run_sync(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any:
        """Run a blocking callable in the default executor and await its result."""
        return await asyncio.to_thread(fn, *args, **kwargs)

run_sync(fn, /, *args, **kwargs) async

Run a blocking callable in the default executor and await its result.

Source code in app/provider/base.py
async def run_sync(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any:
    """Run a blocking callable in the default executor and await its result."""
    return await asyncio.to_thread(fn, *args, **kwargs)

AsyncView

An awaitable face over any provider, native or not.

await async_view(p).get_tiles(...) resolves to p.aget_tiles when p declares native_async and has the twin, and to asyncio.to_thread(p.get_tiles, ...) otherwise. Signatures are the synchronous ones, one to one. Works on providers that never saw the mixin: native_async is read with a default.

Source code in app/provider/base.py
class AsyncView:
    """An awaitable face over any provider, native or not.

    ``await async_view(p).get_tiles(...)`` resolves to ``p.aget_tiles``
    when ``p`` declares ``native_async`` and has the twin, and to
    ``asyncio.to_thread(p.get_tiles, ...)`` otherwise. Signatures are the
    synchronous ones, one to one. Works on providers that never saw the
    mixin: ``native_async`` is read with a default.
    """

    def __init__(self, provider: Any) -> None:
        self._provider = provider

    @property
    def native(self) -> bool:
        """Whether the provider declared itself natively asynchronous."""
        return bool(getattr(self._provider, "native_async", False))

    def __getattr__(self, name: str) -> Callable[..., Coroutine[Any, Any, Any]]:
        """The coroutine for ``name``: the provider's twin, or its sync method in a thread."""
        if name.startswith("_"):
            raise AttributeError(name)
        if self.native:
            twin = getattr(self._provider, f"a{name}", None)
            if twin is not None:
                return twin
        sync = getattr(self._provider, name)

        async def call(*args: Any, **kwargs: Any) -> Any:
            return await asyncio.to_thread(sync, *args, **kwargs)

        call.__name__ = f"a{name}"
        return call

native property

Whether the provider declared itself natively asynchronous.

__getattr__(name)

The coroutine for name: the provider's twin, or its sync method in a thread.

Source code in app/provider/base.py
def __getattr__(self, name: str) -> Callable[..., Coroutine[Any, Any, Any]]:
    """The coroutine for ``name``: the provider's twin, or its sync method in a thread."""
    if name.startswith("_"):
        raise AttributeError(name)
    if self.native:
        twin = getattr(self._provider, f"a{name}", None)
        if twin is not None:
            return twin
    sync = getattr(self._provider, name)

    async def call(*args: Any, **kwargs: Any) -> Any:
        return await asyncio.to_thread(sync, *args, **kwargs)

    call.__name__ = f"a{name}"
    return call

StorageBackedMixin

A store and byte ranges for the provider's data URL, via the storage layer.

Reads data and store_options from the provider_def that :class:AsyncProviderMixin captured: pygeoapi's roots do not keep the definition, and store_options is not one of the attributes they set. No provider imports obstore (ADR-0003).

Source code in app/provider/base.py
class StorageBackedMixin:
    """A store and byte ranges for the provider's ``data`` URL, via the storage layer.

    Reads ``data`` and ``store_options`` from the ``provider_def`` that
    :class:`AsyncProviderMixin` captured: pygeoapi's roots do not keep the
    definition, and ``store_options`` is not one of the attributes they
    set. No provider imports obstore (ADR-0003).
    """

    @cached_property
    def store(self) -> ObjectStore:
        """The object store for the directory or bucket prefix holding ``data``."""
        provider_def = self._captured_provider_def()
        base, _ = split_source(provider_def["data"])
        return load_store(base, provider_def.get("store_options"))

    def byte_ranges(self) -> ObjectRanges:
        """Ranged reads over the ``data`` object itself."""
        return ObjectRanges(self.store, self.object_key)

    def range_cache(self) -> RangeCache | None:
        """Cache for ranged reads of ``data``; None for local data or ``range_cache: false``."""
        provider_def = self._captured_provider_def()
        options = provider_def.get("options") or {}
        if not options.get("range_cache", True) or not is_remote(provider_def["data"]):
            return None
        return range_caches.default_range_cache()

    @cached_property
    def cached_ranges(self) -> CachedRanges | None:
        """Cached, versioned ranged reads of ``data``, when the range cache applies."""
        cache = self.range_cache()
        if cache is None:
            return None
        data = self._captured_provider_def()["data"]
        return CachedRanges(self.store, self.object_key, cache, source=data)

    @property
    def native_store(self) -> Any:
        """The library-level store object, for libraries that read through obstore themselves.

        async-tiff is the model: it takes an obstore store and the key of
        the object and performs its own ranged reads. When the backend
        exposes no such object the store itself is returned.
        """
        return getattr(self.store, "backend", self.store)

    @property
    def object_key(self) -> str:
        """The key of the ``data`` object within :attr:`store`."""
        _, key = split_source(self._captured_provider_def()["data"])
        return key

    def signed_url(self, expires_in: timedelta) -> str:
        """A presigned URL of the ``data`` object, for a reader outside this process.

        The reader fetches the object over HTTP with the URL alone, and the
        credentials stay with the store.
        """
        sign = getattr(self.store, "sign", None)
        if sign is None:
            raise TypeError(f"{type(self.store).__name__} cannot sign URLs")
        return sign(self.object_key, expires_in)

    def definition_digest(self) -> str:
        """A digest of the provider's definition: any option that shapes the output changes it.

        The definition may carry credentials in ``store_options``; only the
        digest leaves this method.
        """
        definition = json.dumps(self._captured_provider_def(), sort_keys=True, default=str)
        return hashlib.sha256(definition.encode()).hexdigest()[:16]

    async def adata_version(self) -> str | None:
        """The version of the ``data`` object, the same in every process; None when unknown.

        A remote object read through the range cache has the ETag the cache
        knows, checked again behind the request once its TTL has passed. A
        remote object read without the cache has none: a HEAD for every
        request would cost more than the cache saves. A local file has its
        size and modification time, since its ETag names the inode, which
        differs from machine to machine.
        """
        cached = self.cached_ranges
        if cached is not None:
            return (await cached.ameta_without_waiting()).etag
        if is_remote(self._captured_provider_def()["data"]):
            return None
        meta = await self.store.ahead(self.object_key)
        modified = meta.last_modified.isoformat() if meta.last_modified is not None else ""
        return f"{meta.size}:{modified}"

    def _captured_provider_def(self) -> dict:
        provider_def = getattr(self, "provider_def", None)
        if provider_def is None:
            raise TypeError(
                f"{type(self).__name__}: provider_def was not captured. Put AsyncProviderMixin "
                "first in the bases, before the pygeoapi root class, which does not call "
                "super().__init__()."
            )
        return provider_def

store cached property

The object store for the directory or bucket prefix holding data.

cached_ranges cached property

Cached, versioned ranged reads of data, when the range cache applies.

native_store property

The library-level store object, for libraries that read through obstore themselves.

async-tiff is the model: it takes an obstore store and the key of the object and performs its own ranged reads. When the backend exposes no such object the store itself is returned.

object_key property

The key of the data object within :attr:store.

byte_ranges()

Ranged reads over the data object itself.

Source code in app/provider/base.py
def byte_ranges(self) -> ObjectRanges:
    """Ranged reads over the ``data`` object itself."""
    return ObjectRanges(self.store, self.object_key)

range_cache()

Cache for ranged reads of data; None for local data or range_cache: false.

Source code in app/provider/base.py
def range_cache(self) -> RangeCache | None:
    """Cache for ranged reads of ``data``; None for local data or ``range_cache: false``."""
    provider_def = self._captured_provider_def()
    options = provider_def.get("options") or {}
    if not options.get("range_cache", True) or not is_remote(provider_def["data"]):
        return None
    return range_caches.default_range_cache()

signed_url(expires_in)

A presigned URL of the data object, for a reader outside this process.

The reader fetches the object over HTTP with the URL alone, and the credentials stay with the store.

Source code in app/provider/base.py
def signed_url(self, expires_in: timedelta) -> str:
    """A presigned URL of the ``data`` object, for a reader outside this process.

    The reader fetches the object over HTTP with the URL alone, and the
    credentials stay with the store.
    """
    sign = getattr(self.store, "sign", None)
    if sign is None:
        raise TypeError(f"{type(self.store).__name__} cannot sign URLs")
    return sign(self.object_key, expires_in)

definition_digest()

A digest of the provider's definition: any option that shapes the output changes it.

The definition may carry credentials in store_options; only the digest leaves this method.

Source code in app/provider/base.py
def definition_digest(self) -> str:
    """A digest of the provider's definition: any option that shapes the output changes it.

    The definition may carry credentials in ``store_options``; only the
    digest leaves this method.
    """
    definition = json.dumps(self._captured_provider_def(), sort_keys=True, default=str)
    return hashlib.sha256(definition.encode()).hexdigest()[:16]

adata_version() async

The version of the data object, the same in every process; None when unknown.

A remote object read through the range cache has the ETag the cache knows, checked again behind the request once its TTL has passed. A remote object read without the cache has none: a HEAD for every request would cost more than the cache saves. A local file has its size and modification time, since its ETag names the inode, which differs from machine to machine.

Source code in app/provider/base.py
async def adata_version(self) -> str | None:
    """The version of the ``data`` object, the same in every process; None when unknown.

    A remote object read through the range cache has the ETag the cache
    knows, checked again behind the request once its TTL has passed. A
    remote object read without the cache has none: a HEAD for every
    request would cost more than the cache saves. A local file has its
    size and modification time, since its ETag names the inode, which
    differs from machine to machine.
    """
    cached = self.cached_ranges
    if cached is not None:
        return (await cached.ameta_without_waiting()).etag
    if is_remote(self._captured_provider_def()["data"]):
        return None
    meta = await self.store.ahead(self.object_key)
    modified = meta.last_modified.isoformat() if meta.last_modified is not None else ""
    return f"{meta.size}:{modified}"

async_view(provider)

The awaitable face of provider; see :class:AsyncView.

Source code in app/provider/base.py
def async_view(provider: Any) -> AsyncView:
    """The awaitable face of ``provider``; see :class:`AsyncView`."""
    return AsyncView(provider)

app.provider.sansio

Sans-I/O cores and the two drivers that feed them (ADR-0010, decision 6).

A core is a generator: it yields (offset, length) requests, receives the bytes, and returns its result. It knows nothing about where the bytes come from, so it is tested once, in memory, and gives a provider both faces for free::

def get_tiles(...):
    return drive_sync(self._tile(z, x, y), self.ranges)

async def aget_tiles(...):
    return await drive(self._tile(z, x, y), self.ranges)

Request = tuple[int, int]

One ranged read: (offset, length).

Core = Generator[Request, bytes, T]

A parser step machine: yields requests, receives bytes, returns T.

drive_sync(core, ranges)

Run core to completion with synchronous reads.

Source code in app/provider/sansio.py
def drive_sync[T](core: Core[T], ranges: ByteRanges) -> T:
    """Run ``core`` to completion with synchronous reads."""
    try:
        offset, length = next(core)
        while True:
            offset, length = core.send(ranges.read(offset, length))
    except StopIteration as done:
        return done.value

drive(core, ranges) async

Run core to completion with awaited reads; no thread involved.

Source code in app/provider/sansio.py
async def drive[T](core: Core[T], ranges: ByteRanges) -> T:
    """Run ``core`` to completion with awaited reads; no thread involved."""
    try:
        offset, length = next(core)
        while True:
            offset, length = core.send(await ranges.aread(offset, length))
    except StopIteration as done:
        return done.value

app.interfaces.providers

Provider capability Protocols (ADR-0010).

pygeoapi's provider contract is synchronous and per family. These Protocols describe the asynchronous capability a provider may add, again per family, and are what the router checks::

isinstance(p, AsyncTileProvider) and p.native_async

Conformance is structural: any object with these members qualifies, whether or not it inherits from fastgeoapi's mixin.

AsyncTileProvider

Bases: Protocol

A tile provider that can be awaited for tile data.

Attributes:

Name Type Description
native_async bool

True only when aget_tiles awaits its I/O for real.

format_type str

The configured tile format name (format.name in the provider definition), passed back as format_.

Source code in app/interfaces/providers.py
@runtime_checkable
class AsyncTileProvider(Protocol):
    """A tile provider that can be awaited for tile data.

    Attributes
    ----------
    native_async
        ``True`` only when ``aget_tiles`` awaits its I/O for real.
    format_type
        The configured tile format name (``format.name`` in the
        provider definition), passed back as ``format_``.
    """

    native_async: ClassVar[bool]
    format_type: str

    def get_layer(self) -> Any:
        """The layer name pygeoapi passes to ``get_tiles``."""
        ...

    async def aget_tiles(
        self,
        layer: Any,
        tileset: str,
        z: int | str,
        y: int | str,
        x: int | str,
        format_: str,
    ) -> bytes | None:
        """The tile bytes, ``None`` when the tile is absent within limits."""
        ...

get_layer()

The layer name pygeoapi passes to get_tiles.

Source code in app/interfaces/providers.py
def get_layer(self) -> Any:
    """The layer name pygeoapi passes to ``get_tiles``."""
    ...

aget_tiles(layer, tileset, z, y, x, format_) async

The tile bytes, None when the tile is absent within limits.

Source code in app/interfaces/providers.py
async def aget_tiles(
    self,
    layer: Any,
    tileset: str,
    z: int | str,
    y: int | str,
    x: int | str,
    format_: str,
) -> bytes | None:
    """The tile bytes, ``None`` when the tile is absent within limits."""
    ...

AsyncFeatureProvider

Bases: Protocol

A feature provider that can be awaited for queries and single items.

Source code in app/interfaces/providers.py
@runtime_checkable
class AsyncFeatureProvider(Protocol):
    """A feature provider that can be awaited for queries and single items."""

    native_async: ClassVar[bool]

    async def aquery(self, **kwargs: Any) -> dict:
        """The async twin of ``query``: same keyword arguments, same GeoJSON result."""
        ...

    async def aget(self, identifier: Any, **kwargs: Any) -> dict:
        """The async twin of ``get``."""
        ...

aquery(**kwargs) async

The async twin of query: same keyword arguments, same GeoJSON result.

Source code in app/interfaces/providers.py
async def aquery(self, **kwargs: Any) -> dict:
    """The async twin of ``query``: same keyword arguments, same GeoJSON result."""
    ...

aget(identifier, **kwargs) async

The async twin of get.

Source code in app/interfaces/providers.py
async def aget(self, identifier: Any, **kwargs: Any) -> dict:
    """The async twin of ``get``."""
    ...

AsyncMapProvider

Bases: Protocol

A map provider that can be awaited for a rendered map.

Source code in app/interfaces/providers.py
@runtime_checkable
class AsyncMapProvider(Protocol):
    """A map provider that can be awaited for a rendered map."""

    native_async: ClassVar[bool]

    async def aquery(self, **kwargs: Any) -> bytes:
        """The async twin of ``query``: same keyword arguments, the image bytes."""
        ...

aquery(**kwargs) async

The async twin of query: same keyword arguments, the image bytes.

Source code in app/interfaces/providers.py
async def aquery(self, **kwargs: Any) -> bytes:
    """The async twin of ``query``: same keyword arguments, the image bytes."""
    ...

VersionedProvider

Bases: Protocol

A provider that tells which version of its answer a request would get.

The HTTP cache builds its ETag from that version before any work is done for the request.

Source code in app/interfaces/providers.py
@runtime_checkable
class VersionedProvider(Protocol):
    """A provider that tells which version of its answer a request would get.

    The HTTP cache builds its ETag from that version before any work is
    done for the request.
    """

    async def aversion(self, **kwargs: Any) -> str | None:
        """A string that changes whenever the answer to ``kwargs`` changes; None when unknown.

        Takes the arguments of the provider's data method, and neither draws
        nor reads data to answer.
        """
        ...

aversion(**kwargs) async

A string that changes whenever the answer to kwargs changes; None when unknown.

Takes the arguments of the provider's data method, and neither draws nor reads data to answer.

Source code in app/interfaces/providers.py
async def aversion(self, **kwargs: Any) -> str | None:
    """A string that changes whenever the answer to ``kwargs`` changes; None when unknown.

    Takes the arguments of the provider's data method, and neither draws
    nor reads data to answer.
    """
    ...

app.pygeoapi.api_async.tiles

Tile data served with await when the provider is natively async (ADR-0010, decision 5).

Mirrors pygeoapi/api/tiles.py::get_collection_tiles_data step by step: same format check, same headers, same 204 on a missing tile, same 404 on ProviderTileNotFoundError, same generic mapping through api.get_exception. The one difference is that the provider is awaited on the event loop instead of being called in the threadpool.

native_tile_provider(api, dataset)

The collection's tile provider definition and instance, when native async.

Returns None for unknown collections, collections without a tile provider, providers that fail to build and providers that are not native: every one of those keeps pygeoapi's own handler, which answers exactly as before. Instantiation goes through pygeoapi.plugin.load_plugin, the cached one after patch_load_plugin, so a THREAD_SAFE provider is built once.

Source code in app/pygeoapi/api_async/tiles.py
def native_tile_provider(api: API, dataset: str) -> tuple[dict, AsyncTileProvider] | None:
    """The collection's tile provider definition and instance, when native async.

    Returns ``None`` for unknown collections, collections without a tile
    provider, providers that fail to build and providers that are not
    native: every one of those keeps pygeoapi's own handler, which
    answers exactly as before. Instantiation goes through
    ``pygeoapi.plugin.load_plugin``, the cached one after
    ``patch_load_plugin``, so a ``THREAD_SAFE`` provider is built once.
    """
    collections = filter_dict_by_key_value(api.config["resources"], "type", "collection")
    if dataset not in collections:
        return None
    try:
        provider_def = get_provider_by_type(api.config["resources"][dataset]["providers"], "tile")
        provider = pygeoapi_plugin.load_plugin("provider", provider_def)
    except (KeyError, ProviderTypeError, ProviderGenericError):
        return None
    if isinstance(provider, AsyncTileProvider) and provider.native_async:
        return provider_def, provider
    return None

get_collection_tiles_data(api, request, dataset, matrix_id, z, y, x, provider_def, provider, http_cache=None) async

(headers, status, content) for one tile, awaiting the provider.

With http_cache, a provider that knows its version gives the tile an ETag, and a request that names it gets a 304 without the tile being read.

Source code in app/pygeoapi/api_async/tiles.py
async def get_collection_tiles_data(
    api: API,
    request: APIRequest,
    dataset: str,
    matrix_id: str,
    z: str,
    y: str,
    x: str,
    provider_def: dict,
    provider: AsyncTileProvider,
    http_cache: HttpCache | None = None,
) -> tuple[dict, int, Any]:
    """``(headers, status, content)`` for one tile, awaiting the provider.

    With ``http_cache``, a provider that knows its version gives the tile
    an ETag, and a request that names it gets a 304 without the tile
    being read.
    """
    if not request.format:
        return api.get_format_exception(request)
    headers = request.get_response_headers(SYSTEM_LOCALE, **api.api_headers)
    format_ = provider.format_type
    try:
        headers["Content-Type"] = provider_def["format"]["mimetype"]
        arguments = {
            "layer": provider.get_layer(),
            "tileset": matrix_id,
            "z": z,
            "y": y,
            "x": x,
            "format_": format_,
        }
        cached = await cache_headers(http_cache, provider, dataset, "tile", arguments, headers)
        asked = get_from_headers(request.headers, "if-none-match")
        if cached and not_modified(asked, cached["ETag"]):
            return not_modified_answer(headers, cached)
        content = await provider.aget_tiles(**arguments)
    except KeyError:
        return api.get_exception(
            HTTPStatus.BAD_REQUEST,
            headers,
            format_,
            "InvalidParameterValue",
            "Invalid collection tiles",
        )
    except ProviderTileNotFoundError:
        return headers, HTTPStatus.NOT_FOUND, "Tile not found"
    except ProviderGenericError as err:
        return api.get_exception(
            err.http_status_code,
            headers,
            request.format,
            err.ogc_exception_code,
            err.message,
        )
    if content is None:
        headers, status, body = api.get_exception(
            HTTPStatus.NO_CONTENT, headers, format_, "NoContent", "identifier not found"
        )
        return {**headers, **cached}, status, body
    headers.update(cached)
    return headers, HTTPStatus.OK, content

The configuration editor

app.editor.inspect

Answer three questions about a configuration document.

Is the document being edited well formed? Is what will actually run well formed? Does it build?

These are deliberately plain functions over text, not endpoints: the CLI wants them, a CI check wants them, and the editor's routes are only one caller. Text rather than a parsed mapping because the distinction between source and effective lives in how the text is loaded — pygeoapi resolves ${VAR} at load time, so a mapping has already lost the difference.

Outcome dataclass

What was asked, and what can be said about it.

Attributes:

Name Type Description
ok bool

Whether the question came back clean.

problems list[str]

One line per problem, phrased for whoever wrote the document — the key at fault and why, never a stack trace.

variables dict[str, str]

The ${VAR} values used while resolving. Present so a reader can tell this answer from the one the deployment would give: the values come from whoever is running the editor.

collections list[str]

For a dry run, the collections that actually stood up.

specs list[str]

With augmented, the OGC API specifications this configuration would mount. fastgeoapi builds its route table from the resources rather than serving everything (ADR-0005), so this is a fact about the document that no pygeoapi tool can report.

tools list[str]

With augmented, the MCP tools an agent would see.

not_reported list[str]

Parts of an augmented answer that could not be produced, and why. Asking for more than is installed is not an error — saying nothing about it would be.

Source code in app/editor/inspect.py
@dataclass(frozen=True)
class Outcome:
    """What was asked, and what can be said about it.

    Attributes
    ----------
    ok
        Whether the question came back clean.
    problems
        One line per problem, phrased for whoever wrote the document —
        the key at fault and why, never a stack trace.
    variables
        The `${VAR}` values used while resolving. Present so a reader
        can tell *this* answer from the one the deployment would give:
        the values come from whoever is running the editor.
    collections
        For a dry run, the collections that actually stood up.
    specs
        With `augmented`, the OGC API specifications this configuration
        would mount. fastgeoapi builds its route table from the
        resources rather than serving everything (ADR-0005), so this is
        a fact about the document that no pygeoapi tool can report.
    tools
        With `augmented`, the MCP tools an agent would see.
    not_reported
        Parts of an augmented answer that could not be produced, and
        why. Asking for more than is installed is not an error — saying
        nothing about it would be.
    """

    ok: bool
    problems: list[str] = field(default_factory=list)
    variables: dict[str, str] = field(default_factory=dict)
    collections: list[str] = field(default_factory=list)
    specs: list[str] = field(default_factory=list)
    tools: list[str] = field(default_factory=list)
    not_reported: list[str] = field(default_factory=list)

validate_source(text)

Check the document as written, placeholders and all.

Source code in app/editor/inspect.py
def validate_source(text: str) -> Outcome:
    """Check the document as written, placeholders and all."""
    config, problem = _parse(text)
    if config is None:
        return Outcome(ok=False, problems=[problem or "unparseable"])
    problems = _schema_problems(config, forgive_placeholders=True)
    return Outcome(ok=not problems, problems=problems)

validate_effective(text)

Check what would actually run, once the placeholders are resolved.

Source code in app/editor/inspect.py
def validate_effective(text: str) -> Outcome:
    """Check what would actually run, once the placeholders are resolved."""
    variables, missing = _resolve(text)
    if missing:
        return Outcome(ok=False, problems=missing, variables=variables)

    from pygeoapi.util import yaml_load

    try:
        config = yaml_load(text)
    except Exception as e:  # a resolved document can still fail to load
        return Outcome(ok=False, problems=[str(e)], variables=variables)

    problems = _schema_problems(config, forgive_placeholders=False)
    return Outcome(ok=not problems, problems=problems, variables=variables)

dry_run(text, augmented=False)

Build the configuration for real, and report what stood up.

This is what validation cannot do: a document can satisfy the schema and still name a file that is not there, a provider that will not import, or a bucket nothing can reach.

With augmented, it also reports what fastgeoapi would make of the document beyond serving it: which specifications get mounted, and which MCP tools an agent would see. Both are answers pygeoapi has no way to give — and both are optional, because this command has to keep working for someone who only has pygeoapi.

It answers "does this build here", never "will this work in production": the build uses the environment and the credentials of whoever is running the editor. variables carries what was substituted so a reader can tell the difference — an outcome that hid it would invite reading a green as a guarantee it cannot give.

Source code in app/editor/inspect.py
def dry_run(text: str, augmented: bool = False) -> Outcome:
    """Build the configuration for real, and report what stood up.

    This is what validation cannot do: a document can satisfy the schema
    and still name a file that is not there, a provider that will not
    import, or a bucket nothing can reach.

    With `augmented`, it also reports what fastgeoapi would make of the
    document beyond serving it: which specifications get mounted, and
    which MCP tools an agent would see. Both are answers pygeoapi has no
    way to give — and both are optional, because this command has to
    keep working for someone who only has pygeoapi.

    It answers **"does this build here"**, never "will this work in
    production": the build uses the environment and the credentials of
    whoever is running the editor. `variables` carries what was
    substituted so a reader can tell the difference — an outcome that
    hid it would invite reading a green as a guarantee it cannot give.
    """
    effective = validate_effective(text)
    if not effective.ok:
        return effective

    from pygeoapi.util import yaml_load

    from app.pygeoapi.factory import build_openapi, build_pygeoapi_subapp

    config: dict[str, Any] = yaml_load(text)
    try:
        openapi = build_openapi(config)
        subapp = build_pygeoapi_subapp(config, openapi)
    except Exception as e:
        return Outcome(ok=False, problems=[_blame(config, e)], variables=effective.variables)

    # Building is not enough, and neither is asking: pygeoapi's GeoJSON
    # provider answers 200 with an empty FeatureCollection when its file
    # is not there, so "no features" and "no file" look identical over
    # HTTP. The data source is therefore checked directly — which is the
    # case ADR-0008 uses to justify a dry run in the first place: a
    # configuration that validates while the provider cannot read what
    # it names.
    unreachable = [
        f"resource '{name}': cannot reach the data source '{data}' — {why}"
        for name, data, why in _unreachable_sources(config)
    ]

    # Building is not enough. A provider is constructed without touching
    # its data — a GeoJSON pointing at a file that is not there builds
    # perfectly and fails on the first read — so each collection is asked
    # for a single item. That is the difference between "it imported"
    # and "it can serve", and the second is what an operator wants to
    # know before saving.
    from starlette.testclient import TestClient

    client = TestClient(subapp, raise_server_exceptions=False)
    served, problems = [], []
    for name, resource in (config.get("resources") or {}).items():
        if resource.get("type") != "collection":
            continue
        response = client.get(
            f"/collections/{name}/items?limit=1", headers={"Accept": "application/json"}
        )
        if response.status_code == 200:
            served.append(name)
        else:
            problems.append(
                f"resource '{name}': asking for one item answered "
                f"{response.status_code} — {response.text[:160]}"
            )

    problems = unreachable + problems
    specs: list[str] = []
    tools: list[str] = []
    not_reported: list[str] = []
    if augmented:
        specs, tools, not_reported = _augmented(config, openapi)

    return Outcome(
        ok=not problems,
        problems=problems,
        variables=effective.variables,
        collections=sorted(served),
        specs=specs,
        tools=tools,
        not_reported=not_reported,
    )