Skip to content

Codec

Documents in, FeatureCollections out - and back.

A codec turns a whole document into a FeatureCollection and back, and one feature into that encoding's representation of it (encode_feature / decode_feature). Two concerns that used to be one class are separate:

  • Source and Target say where a document's bytes are - on disk, behind a URL, in memory - and nothing about what is in them.
  • A Codec says what the bytes mean, and is stateless.

read and write put the two together, which is all most callers need:

from xmas_core.codec import read, write

collection = read("plan.gml")                      # encoding from the content
collection = read(wfs_url, allow_remote=True)      # ...which is why a URL works
write(collection, "out.json")                      # encoding from the target's name
write(collection, sys.stdout.buffer, format="gml") # ...or said outright

Where the encoding is known up front, the codec module is reached directly:

from xmas_core.codec import Source, gml

collection = gml.decode(Source("plan.gml"))

Reading identifies the encoding from the document's content - an XML document by its root element, JSON-FG by its first byte - and falls back to the file extension. Writing goes by the target's name only, because whatever is at the target is about to be replaced and so says nothing about what is being written.

Adding an encoding is a module here, its name and file extensions in datasource - Format, _SUFFIX_FORMATS, and a _sniff branch when the document opens with neither < nor { - and one entry in CODECS. There is deliberately no registration call that would do all four at once: Format is a Literal, which is what checks a caller's format= and CODECS[encoding] statically, and a runtime registry would have to give that up. Nothing derives the list from anything else either - datasource sniffs to a Format and knows no codec, and each codec knows only its own - so the two halves are held together from outside: the type checker refuses a CODECS key that is not a Format, and test_every_registered_codec_satisfies_the_interface refuses a Format with no codec behind it. A codec is stateless, so it is its module - gml.decode(source), nothing to construct.

A database is not a document: it holds no stream of bytes, and DBRepository addresses one through SQLAlchemy. A connection URL handed here is refused with that route named.

CODECS module-attribute

CODECS: Final[dict[Format, Codec]] = {'gml': gml, 'jsonfg': jsonfg}

The encoding each Format is read and written with.

The modules themselves: a codec holds no state, so there is nothing to construct. This is the only place that knows the list; datasource identifies a document as a Format without knowing what reads it.

read

read(source: Any, *, allow_remote: bool | None = None, **options: Any) -> FeatureCollection

Reads a whole document, whatever encoding it turns out to be.

The encoding is identified from the content, so a document served under no usable name - a WFS GetFeature response, say - is still read correctly.

Parameters:

Name Type Description Default
source Any

A file path as a string or os.PathLike, an http(s) URL, or a readable file-like object.

required
allow_remote bool | None

Whether an http(s) source may be retrieved; defaults to the XMAS_DS_ALLOW_REMOTE setting.

None
**options Any

Read options for the encoding; see gml.decode and jsonfg.decode.

{}

Raises:

Type Description
DatasourceError

The document could not be identified.

UnsupportedDatasourceError

The source names a location this package refuses to open, or is remote while remote access is disabled.

ForbiddenDoctypeError

The document is XML and declares a DTD.

UnsupportedRootElementError

The document is XML, but opens with an element this package does not read.

RemoteFetchError

A remote source could not be retrieved.

Returns:

Type Description
FeatureCollection

The collection the document holds.

Source code in xmas_core/codec/__init__.py
def read(
    source: Any, *, allow_remote: bool | None = None, **options: Any
) -> FeatureCollection:
    """Reads a whole document, whatever encoding it turns out to be.

    The encoding is identified from the content, so a document served under no usable name
    - a WFS ``GetFeature`` response, say - is still read correctly.

    Args:
        source: A file path as a string or `os.PathLike`, an ``http(s)`` URL, or a readable
            file-like object.
        allow_remote: Whether an ``http(s)`` source may be retrieved; defaults to the
            ``XMAS_DS_ALLOW_REMOTE`` setting.
        **options: Read options for the encoding; see
            [`gml.decode`][xmas_core.codec.gml.decode] and
            [`jsonfg.decode`][xmas_core.codec.jsonfg.decode].

    Raises:
        DatasourceError: The document could not be identified.
        UnsupportedDatasourceError: The source names a location this package refuses to
            open, or is remote while remote access is disabled.
        ForbiddenDoctypeError: The document is XML and declares a DTD.
        UnsupportedRootElementError: The document is XML, but opens with an element this
            package does not read.
        RemoteFetchError: A remote source could not be retrieved.

    Returns:
        The collection the document holds.
    """
    src = Source(source, allow_remote=allow_remote)
    codec = CODECS.get(src.format) if src.format else None
    if codec is None:
        raise DatasourceError(f"cannot tell what encoding {src!r} is written in")
    return codec.decode(src, **options)

write

write(collection: FeatureCollection, target: Any, *, format: Format | None = None, **options: Any) -> None

Writes a collection as a document, replacing whatever is at target.

The encoding comes from the target's name. Where the name does not settle it - a file-like object, or an extension this package does not know - pass format.

Parameters:

Name Type Description Default
collection FeatureCollection

The collection to write.

required
target Any

A file path as a string or os.PathLike, or a writable file-like object.

required
format Format | None

The encoding to write, when the target's name does not give it.

None
**options Any

Write options for the encoding; see gml.encode and jsonfg.encode.

{}

Raises:

Type Description
DatasourceError

The encoding could not be determined from the target's name.

UnsupportedDatasourceError

The target names a location this package refuses to write to.

Source code in xmas_core/codec/__init__.py
def write(
    collection: FeatureCollection,
    target: Any,
    *,
    format: Format | None = None,
    **options: Any,
) -> None:
    """Writes a collection as a document, replacing whatever is at `target`.

    The encoding comes from the target's name. Where the name does not settle it - a
    file-like object, or an extension this package does not know - pass `format`.

    Args:
        collection: The collection to write.
        target: A file path as a string or `os.PathLike`, or a writable file-like object.
        format: The encoding to write, when the target's name does not give it.
        **options: Write options for the encoding; see
            [`gml.encode`][xmas_core.codec.gml.encode] and
            [`jsonfg.encode`][xmas_core.codec.jsonfg.encode].

    Raises:
        DatasourceError: The encoding could not be determined from the target's name.
        UnsupportedDatasourceError: The target names a location this package refuses to
            write to.
    """
    tgt = Target(target)
    codec = CODECS.get(fmt) if (fmt := format or tgt.format) else None
    if codec is None:
        raise DatasourceError(
            f"cannot tell what encoding to write to {tgt!r}; name it with "
            f"format={'|'.join(CODECS)}"
        )
    codec.encode(collection, tgt, **options)

Codec

Bases: Protocol

Decodes a document into a collection, and encodes one back out.

Everything either direction needs arrives as an argument, which is why an implementation can be a module: there is no state for one to hold.

decode

decode(source: Source, **options: Any) -> FeatureCollection

Reads the whole document at source.

Parameters:

Name Type Description Default
source Source

The document to read.

required
**options Any

Read options this encoding understands; the rest are ignored.

{}

Returns:

Type Description
FeatureCollection

The collection the document holds.

Source code in xmas_core/codec/interface.py
def decode(self, source: Source, **options: Any) -> FeatureCollection:
    """Reads the whole document at `source`.

    Args:
        source: The document to read.
        **options: Read options this encoding understands; the rest are ignored.

    Returns:
        The collection the document holds.
    """
    ...

encode

encode(collection: FeatureCollection, target: Target, **options: Any) -> None

Writes collection to target, replacing whatever is there.

Parameters:

Name Type Description Default
collection FeatureCollection

The collection to write.

required
target Target

Where to write it.

required
**options Any

Write options this encoding understands; the rest are ignored.

{}
Source code in xmas_core/codec/interface.py
def encode(
    self, collection: FeatureCollection, target: Target, **options: Any
) -> None:
    """Writes `collection` to `target`, replacing whatever is there.

    Args:
        collection: The collection to write.
        target: Where to write it.
        **options: Write options this encoding understands; the rest are ignored.
    """
    ...

datasource

Where a document's bytes are, and how to get at them.

A codec decodes and encodes; it has no business knowing whether the bytes sit on disk, behind a URL or already in memory. That is what Source and Target hold, which is why the codecs in this package take one of them and keep no state of their own.

from xmas_core.codec.datasource import Source, Target

Source("plan.gml").read_bytes()          # opened here, not before
Source(wfs_url, allow_remote=True).format  # "gml", from what it served
Target("out.gml").write_bytes(payload)

Reading and writing are not symmetric, so they are two classes rather than one with a flag:

  • A source is identified by its content - an XML document by its root element, JSON-FG by its first byte - and only then by its file extension, so a URL serving a document under no usable name is still read correctly. Content sniffing is done here rather than by GDAL, which took 75 s to identify a 68 MB GML.
  • A target is identified by its name alone. Whatever is at it is about to be replaced, so it says nothing about what is being written, and reading it would refuse to overwrite a document this package cannot read.
  • A target is never fetched, so an http(s) URL is not a Target at all and is refused when one is constructed, rather than at the write.

Construction classifies and validates but never reads: a path is opened, and a remote URL retrieved, only when something first needs the bytes. The remote policy is enforced eagerly, since consulting it costs nothing. Retrieval happens here rather than inside lxml, so the parser keeps its network access switched off and the timeout, redirect and size limits live in one place. Remote access is off unless XMAS_DS_ALLOW_REMOTE is set or allow_remote is passed; with it on, the URL is retrieved as given, so a caller forwarding an untrusted source is responsible for restricting where it may point.

A database is not a document: it holds no stream of bytes, and DBRepository addresses one through SQLAlchemy. A connection URL handed here is refused with that route named.

Format module-attribute

Format = Literal['gml', 'jsonfg']

The document encodings this package reads and writes.

Kind module-attribute

Kind = Literal['path', 'url', 'buffer']

Where a datasource's bytes are.

Source

Source(source: Any, *, allow_remote: bool | None = None)

Bases: _Datasource

A document to read: where its bytes are, and which encoding they are in.

Built once and handed to the codec that reads it, so a remote document is retrieved once and a one-shot stream drained once, however many times the encoding and the root element are consulted on the way.

Usage example
source = Source("plan.gml")
source.format          # "gml"
source.read_bytes()    # opened here, not at construction

Attributes:

Name Type Description
raw

The source exactly as it was handed over.

kind Kind

Where its bytes are.

allow_remote

Whether retrieval is permitted; None defers to the XMAS_DS_ALLOW_REMOTE setting.

Classifies and validates a source, without reading it.

Parameters:

Name Type Description Default
source Any

A file path as a string or os.PathLike, an http(s) URL, or a readable file-like object.

required
allow_remote bool | None

Whether an http(s) source may be retrieved; defaults to the XMAS_DS_ALLOW_REMOTE setting.

None

Raises:

Type Description
UnsupportedDatasourceError

The source names a location this package refuses to open, or is remote while remote access is disabled.

Source code in xmas_core/codec/datasource.py
def __init__(self, source: Any, *, allow_remote: bool | None = None) -> None:
    """Classifies and validates a source, without reading it.

    Args:
        source: A file path as a string or `os.PathLike`, an ``http(s)`` URL, or a
            readable file-like object.
        allow_remote: Whether an ``http(s)`` source may be retrieved; defaults to the
            ``XMAS_DS_ALLOW_REMOTE`` setting.

    Raises:
        UnsupportedDatasourceError: The source names a location this package refuses
            to open, or is remote while remote access is disabled.
    """
    self.allow_remote = allow_remote
    super().__init__(source)
    if self.kind == "url" and not self._remote_allowed:
        raise UnsupportedDatasourceError(
            f"remote datasources are disabled, refusing {_mask(source)!r}; set "
            "XMAS_DS_ALLOW_REMOTE=1 or pass --allow-remote to enable them"
        )

buffer cached property

buffer: BytesIO | StringIO | None

The source's bytes, when they are in memory rather than on disk.

Retrieves a remote source on first access. A path is left for the codec to open, so a large file is never read into memory here. A stream that cannot be rewound is drained into a BytesIO once, so that identifying the encoding does not consume it and leave the codec with nothing.

Raises:

Type Description
RemoteFetchError

A remote source could not be retrieved.

content property

content: Any

What the codec reads: the buffer if there is one, else the path.

format property

format: Format | None

The encoding this document is written in, or None if it is unidentified.

Read from the content, falling back to the file extension. Sniffing is what retrieves a remote document, so the bytes it read are the ones the codec goes on to parse.

Raises:

Type Description
ForbiddenDoctypeError

The document is XML and declares a DTD.

UnsupportedRootElementError

The document is XML, but opens with an element this package does not read.

RemoteFetchError

A remote source could not be retrieved.

root_info property

root_info: RootInfo

The root element of an XML document, read without parsing the rest of it.

Identifying the encoding already read it, so a GML document is preflighted once however it was reached.

Raises:

Type Description
ForbiddenDoctypeError

The document declares a DTD.

UnsupportedRootElementError

The document opens with an element this package does not read.

XMLParseError

The document does not hold well-formed XML.

read_bytes

read_bytes() -> bytes

Returns the whole document, retrieving a remote one on the way.

A path is opened and closed here; a buffer is rewound and put back where it was found, so a caller's stream comes back as it was handed over.

Raises:

Type Description
RemoteFetchError

A remote source could not be retrieved.

OSError

The path could not be opened.

Returns:

Type Description
bytes

The document's bytes.

Source code in xmas_core/codec/datasource.py
def read_bytes(self) -> bytes:
    """Returns the whole document, retrieving a remote one on the way.

    A path is opened and closed here; a buffer is rewound and put back where it was
    found, so a caller's stream comes back as it was handed over.

    Raises:
        RemoteFetchError: A remote source could not be retrieved.
        OSError: The path could not be opened.

    Returns:
        The document's bytes.
    """
    with as_binary_stream(self.content) as stream:
        return stream.read()

Target

Target(target: Any)

Bases: _Datasource

A location to write a document to.

A target is never fetched, so an http(s) URL is refused here rather than at the write, and there is nothing to read: whatever is at the target now is about to be replaced.

Usage example
Target("out.gml").write_bytes(payload)

Attributes:

Name Type Description
raw

The target exactly as it was handed over.

kind Kind

Where its bytes go.

Classifies and validates a target, without writing to it.

Parameters:

Name Type Description Default
target Any

A file path as a string or os.PathLike, or a writable file-like object.

required

Raises:

Type Description
UnsupportedDatasourceError

The target names a location this package refuses to write to.

Source code in xmas_core/codec/datasource.py
def __init__(self, target: Any) -> None:
    """Classifies and validates a target, without writing to it.

    Args:
        target: A file path as a string or `os.PathLike`, or a writable file-like
            object.

    Raises:
        UnsupportedDatasourceError: The target names a location this package refuses
            to write to.
    """
    super().__init__(target)
    if self.kind == "url":
        raise UnsupportedDatasourceError(
            f"{_mask(target)!r} cannot be written to; a document is written to a file path "
            "or a file-like object, and a database with DBRepository"
        )

format property

format: Format | None

The encoding this target's name asks for, or None when it does not say.

The counterpart of Source.format, and deliberately not the same rule: there is nothing at a target worth reading, because it is about to be replaced.

sibling

sibling(name: str) -> Target

A target next to this one, named after it.

What a codec that writes a family of documents rather than one needs: JSON-FG writes one document per feature type, and writing them all here would replace the target once per type and leave only the last. The sibling keeps the target's extension, so it is the same encoding by construction.

Parameters:

Name Type Description Default
name str

Appended to the target's stem, after an underscore.

required

Raises:

Type Description
DatasourceError

The target is not a file path, so it names no family.

Returns:

Type Description
Target

A target over the sibling path.

Source code in xmas_core/codec/datasource.py
def sibling(self, name: str) -> Target:
    """A target next to this one, named after it.

    What a codec that writes a family of documents rather than one needs: JSON-FG
    writes one document per feature type, and writing them all here would replace the
    target once per type and leave only the last. The sibling keeps the target's
    extension, so it is the same encoding by construction.

    Args:
        name: Appended to the target's stem, after an underscore.

    Raises:
        DatasourceError: The target is not a file path, so it names no family.

    Returns:
        A target over the sibling path.
    """
    if self.kind != "path":
        raise DatasourceError(
            f"{self!r} names a single document; writing one per {name!r} needs a "
            "file path to write beside"
        )
    path = Path(self.raw)
    return Target(path.parent / f"{path.stem}_{name}{path.suffix}")

write_bytes

write_bytes(data: bytes) -> int

Writes data to the target, encoding for a text buffer.

Parameters:

Name Type Description Default
data bytes

The serialized document.

required

Raises:

Type Description
OSError

The path could not be written.

Returns:

Type Description
int

The number of bytes, or characters for a text buffer, written.

Source code in xmas_core/codec/datasource.py
def write_bytes(self, data: bytes) -> int:
    """Writes `data` to the target, encoding for a text buffer.

    Args:
        data: The serialized document.

    Raises:
        OSError: The path could not be written.

    Returns:
        The number of bytes, or characters for a text buffer, written.
    """
    if self.kind == "path":
        return Path(self.raw).write_bytes(data)
    return self._replace_buffer(
        data.decode("utf-8") if isinstance(self.raw, io.TextIOBase) else data
    )

fetch_remote

fetch_remote(url: str, *, allow_remote: bool | None = None, timeout: float | None = None, max_bytes: int | None = None) -> BytesIO

Retrieves a remote document into a buffer.

The buffer can be handed straight to a Source. Retrieval happens here, rather than inside lxml, so that the parser needs no network access of its own.

Parameters:

Name Type Description Default
url str

An http or https URL.

required
allow_remote bool | None

Whether remote retrieval is permitted; defaults to the XMAS_DS_ALLOW_REMOTE setting.

None
timeout float | None

Seconds to wait; defaults to the XMAS_DS_REMOTE_TIMEOUT setting.

None
max_bytes int | None

Largest response body accepted; defaults to the XMAS_DS_REMOTE_MAX_BYTES setting.

None

Raises:

Type Description
UnsupportedDatasourceError

Remote retrieval is disabled, or the URL does not name an http(s) resource.

RemoteFetchError

The request failed, or the response exceeded max_bytes.

Returns:

Type Description
BytesIO

The response body, positioned at the start.

Source code in xmas_core/codec/datasource.py
def fetch_remote(
    url: str,
    *,
    allow_remote: bool | None = None,
    timeout: float | None = None,
    max_bytes: int | None = None,
) -> io.BytesIO:
    """Retrieves a remote document into a buffer.

    The buffer can be handed straight to a `Source`. Retrieval happens here, rather than
    inside `lxml`, so that the parser needs no network access of its own.

    Args:
        url: An ``http`` or ``https`` URL.
        allow_remote: Whether remote retrieval is permitted; defaults to the
            ``XMAS_DS_ALLOW_REMOTE`` setting.
        timeout: Seconds to wait; defaults to the ``XMAS_DS_REMOTE_TIMEOUT`` setting.
        max_bytes: Largest response body accepted; defaults to the
            ``XMAS_DS_REMOTE_MAX_BYTES`` setting.

    Raises:
        UnsupportedDatasourceError: Remote retrieval is disabled, or the URL does not name
            an ``http(s)`` resource.
        RemoteFetchError: The request failed, or the response exceeded `max_bytes`.

    Returns:
        The response body, positioned at the start.
    """
    import httpx2  # deferred so this module does not depend on it being importable

    settings = get_settings()
    if allow_remote is None:
        allow_remote = settings.ds_allow_remote
    if timeout is None:
        timeout = settings.ds_remote_timeout
    if max_bytes is None:
        max_bytes = settings.ds_remote_max_bytes

    if urlsplit(url).scheme.lower() not in _HTTP_SCHEMES:
        raise UnsupportedDatasourceError(
            f"only http(s) datasources can be retrieved, got {url!r}"
        )
    if not allow_remote:
        raise UnsupportedDatasourceError(
            f"remote datasources are disabled, refusing to retrieve {_mask(url)!r}; set "
            "XMAS_DS_ALLOW_REMOTE=1 or pass --allow-remote to enable them"
        )

    buffer = io.BytesIO()
    size = 0
    try:
        with (
            httpx2.Client(
                timeout=timeout, follow_redirects=True, max_redirects=5
            ) as client,
            client.stream("GET", url) as response,
        ):
            response.raise_for_status()
            for chunk in response.iter_bytes():
                size += len(chunk)
                if size > max_bytes:
                    raise RemoteFetchError(
                        f"{url!r} exceeds the {max_bytes} byte limit; raise "
                        "XMAS_DS_REMOTE_MAX_BYTES to allow it"
                    )
                buffer.write(chunk)
            final_url = str(response.url)
    except httpx2.HTTPStatusError as e:
        raise RemoteFetchError(f"{url!r} returned HTTP {e.response.status_code}") from e
    except httpx2.HTTPError as e:
        raise RemoteFetchError(f"could not retrieve {url!r}: {e}") from e

    logger.info(f"retrieved {size} bytes from {final_url}")
    buffer.seek(0)
    return buffer

gml

The GML codec: a whole XPlanGML document in, a FeatureCollection out, and back.

encode_feature and decode_feature turn one feature into one gml:featureMember and back. Both directions are driven by the appschema metadata - a property's stereotype, typename and uom - rather than by the shape of the data or of the document. A data type is encoded inline inside its owner, so the recursion into it only needs its class metadata. Everything else here is what a document adds on top: which element it opens with, which namespaces it declares, where its SRS is written, and the document-wide gml:id bookkeeping that no single feature can do on its own.

Reading a document is two passes over its feature elements, because every id has to be settled before the first feature is read: a gml:id that is not a UUID, or that repeats a UUID another feature already claimed, is replaced, and every xlink:href naming it has to be repointed before anything is validated.

The codec is this module: it holds no state, so there is nothing to construct.

from xmas_core.codec import gml
from xmas_core.codec.datasource import Source, Target

collection = gml.decode(Source("plan.gml"))
gml.encode(collection, Target("out.gml"))

decode

decode(source: Source, *, always_generate_ids: bool = False, context: dict[str, Any] | None = None, **_: Any) -> FeatureCollection

Reads a whole GML document.

Parameters:

Name Type Description Default
source Source

The document to read.

required
always_generate_ids bool

Generate new feature ids even where the gml:id parses as a UUID.

False
context dict[str, Any] | None

Pydantic validation context for the collection. Setting DROP_INVALID_REFS in it makes unresolvable and external references be dropped rather than raise, and the dropped InvalidReferences are collected in the same dict under INVALID_REFS for the caller to read back. It does not cover a malformed xlink:href - one that is neither intra-document (#GML_<uuid>, urn:uuid:<uuid>) nor an absolute URI - which fails while the feature itself is read and so never reaches the collection.

None
**_ Any

Options for another encoding, ignored here.

{}

Raises:

Type Description
AppschemaNotFoundError

No supported application schema was declared.

SRSNotFoundError

The document declares no SRS.

DuplicateGmlIdError

Two features share a gml:id.

Returns:

Type Description
FeatureCollection

The collection the document holds.

Source code in xmas_core/codec/gml.py
def decode(
    source: Source,
    *,
    always_generate_ids: bool = False,
    context: dict[str, Any] | None = None,
    **_: Any,
) -> FeatureCollection:
    """Reads a whole GML document.

    Args:
        source: The document to read.
        always_generate_ids: Generate new feature ids even where the ``gml:id`` parses
            as a UUID.
        context: Pydantic validation context for the collection. Setting
            `DROP_INVALID_REFS` in it makes unresolvable and external references be
            dropped rather than raise, and the dropped `InvalidReference`s are
            collected in the same dict under `INVALID_REFS` for the caller to read
            back. It does not cover a malformed ``xlink:href`` - one that is neither
            intra-document (``#GML_<uuid>``, ``urn:uuid:<uuid>``) nor an absolute URI -
            which fails while the feature itself is read and so never reaches the
            collection.
        **_: Options for another encoding, ignored here.

    Raises:
        AppschemaNotFoundError: No supported application schema was declared.
        SRSNotFoundError: The document declares no SRS.
        DuplicateGmlIdError: Two features share a ``gml:id``.

    Returns:
        The collection the document holds.
    """
    appschema = _appschema_from_root(source.root_info)
    root: etree._Element = parse_hardened(source.content).getroot()
    srs = _document_srs(root)

    # every id is settled before the first feature is read, so the same elements are
    # walked twice
    feature_elements = list(_feature_elements(root))
    _settle_ids(feature_elements, root, always_generate_ids)

    collection = {}
    for feature in feature_elements:
        model = decode_feature(
            appschema.model_factory(etree.QName(feature).localname, FeatureType),
            feature,
            srs,
        )
        collection[model.id] = model
    return FeatureCollection.from_features(
        collection, srs.srid, appschema, context=context
    )

decode_feature

decode_feature(cls: type[F], element: _Element, srs: SRS) -> F

Decodes one feature's GML element as cls.

Parameters:

Name Type Description Default
cls type[F]

The feature type the element names.

required
element _Element

The feature's element.

required
srs SRS

The document's SRS, for a geometry that names none of its own.

required

Returns:

Type Description
F

The validated feature.

Source code in xmas_core/codec/gml.py
def decode_feature[F: FeatureType](
    cls: type[F], element: etree._Element, srs: SRS
) -> F:
    """Decodes one feature's GML element as `cls`.

    Args:
        cls: The feature type the element names.
        element: The feature's element.
        srs: The document's SRS, for a geometry that names none of its own.

    Returns:
        The validated feature.
    """
    return cls.model_validate(_dump_from_element(cls, element, srs))

encode

encode(collection: FeatureCollection, target: Target, *, feature_srs: bool = True, **_: Any) -> None

Writes a collection as a GML document.

Parameters:

Name Type Description Default
collection FeatureCollection

The collection to write.

required
target Target

Where to write it.

required
feature_srs bool

Write srsName on each feature's geometry.

True
**_ Any

Options for another encoding, ignored here.

{}

Raises:

Type Description
AppschemaNotFoundError

The collection's application schema has no GML encoding in this package.

Source code in xmas_core/codec/gml.py
def encode(
    collection: FeatureCollection,
    target: Target,
    *,
    feature_srs: bool = True,
    **_: Any,
) -> None:
    """Writes a collection as a GML document.

    Args:
        collection: The collection to write.
        target: Where to write it.
        feature_srs: Write ``srsName`` on each feature's geometry.
        **_: Options for another encoding, ignored here.

    Raises:
        AppschemaNotFoundError: The collection's application schema has no GML
            encoding in this package.
    """
    appschema = collection.appschema
    try:
        ns_prefixes, root_tag, schema_location = _GML_PROFILES[appschema.prefix]
    except KeyError:
        raise AppschemaNotFoundError(
            f"cannot write GML for appschema prefix {appschema.prefix!r}"
        ) from None

    namespace = str(appschema.namespace_uri).rstrip("/")
    nsmap: dict[str | None, str] = {None: namespace, **dict(ns_prefixes)}
    root = etree.Element(
        root_tag,
        attrib={
            f"{{{_XSI_NS}}}schemaLocation": schema_location.format(
                ns=namespace, version=appschema.version
            ),
            # every other id written is GML_<uuid>, so the root's own name is unique
            _GML_ID: etree.QName(root_tag).localname,
        },
        # lxml-stubs leave out the None key a default namespace is bound under
        nsmap=nsmap,  # type: ignore[arg-type]  # ty: ignore[invalid-argument-type]
    )

    is_xplan = appschema.prefix == "xplan"
    if is_xplan:
        bounds = etree.SubElement(root, f"{{{_GML_NS}}}boundedBy")

    member_tag = f"{{{_GML_NS if is_xplan else _SF_NS}}}featureMember"
    geoms = []
    for feature in collection.get_features():
        if feature.get_name() in appschema.top_level_featuretypes and (
            wkt := feature.get_geom_wkt()
        ):
            geoms.append(wkt)
        etree.SubElement(root, member_tag).append(
            encode_feature(feature, feature_srs=feature_srs)
        )

    if is_xplan:
        bbox = get_envelope(geoms)
        envelope = etree.SubElement(
            bounds,
            f"{{{_GML_NS}}}Envelope",
            attrib={"srsName": format_srs(collection.srid, "short")},
        )
        etree.SubElement(
            envelope, f"{{{_GML_NS}}}lowerCorner"
        ).text = f"{bbox[0]} {bbox[2]}"
        etree.SubElement(
            envelope, f"{{{_GML_NS}}}upperCorner"
        ).text = f"{bbox[1]} {bbox[3]}"

    target.write_bytes(
        etree.tostring(
            etree.ElementTree(root),
            pretty_print=True,
            xml_declaration=True,
            encoding="UTF-8",
        )
    )

encode_feature

encode_feature(feature: FeatureType, *, feature_srs: bool = True) -> _Element

Encodes one feature as its GML element, the content of a gml:featureMember.

Parameters:

Name Type Description Default
feature FeatureType

The feature to encode.

required
feature_srs bool

Write srsName on its geometry.

True

Returns:

Type Description
_Element

The feature's element.

Source code in xmas_core/codec/gml.py
def encode_feature(feature: FeatureType, *, feature_srs: bool = True) -> etree._Element:
    """Encodes one feature as its GML element, the content of a ``gml:featureMember``.

    Args:
        feature: The feature to encode.
        feature_srs: Write ``srsName`` on its geometry.

    Returns:
        The feature's element.
    """
    # dumped once, nested values included; a value in a role admitting several data
    # types names its class under the discriminator
    data = feature.model_dump(
        mode="json",
        exclude_unset=True,
        exclude_none=True,
        context={POLYMORPHIC_DISCRIMINATOR: True},
    )
    return _element_from_dump(type(feature), data, feature_srs)

jsonfg

The JSON-FG codec: a whole JSON-FG document in, a FeatureCollection out, and back.

encode_feature and decode_feature turn one feature into one JSON-FG feature object and back. Everything else here is what a document adds: the collection object that wraps the features, the describedby link the appschema is read from, the conformance classes it declares, and the document-wide id bookkeeping.

JSON-FG 1.0 lets a document declare its feature type once on the collection (req 2 H) instead of on every feature, which is what writing one document per feature type produces. Both shapes are read, and either can be written: single_collection=False fans the collection out into one document per feature type, written beside the target.

The codec is this module: it holds no state, so there is nothing to construct.

from xmas_core.codec import jsonfg
from xmas_core.codec.datasource import Source, Target

collection = jsonfg.decode(Source("plan.json"))
jsonfg.encode(collection, Target("out.json"))

decode

decode(source: Source, *, context: dict[str, Any] | None = None, **_: Any) -> FeatureCollection

Reads a whole JSON-FG document.

Parameters:

Name Type Description Default
source Source

The document to read.

required
context dict[str, Any] | None

Pydantic validation context for the collection. Setting DROP_INVALID_REFS in it makes unresolvable and external references be dropped rather than raise, and the dropped InvalidReferences are collected in the same dict under INVALID_REFS for the caller to read back.

None
**_ Any

Options for another encoding, ignored here.

{}

Raises:

Type Description
JsonFGParseError

The document is not readable JSON-FG.

AppschemaNotFoundError

No supported application schema was declared.

Returns:

Type Description
FeatureCollection

The collection the document holds.

Source code in xmas_core/codec/jsonfg.py
def decode(
    source: Source,
    *,
    context: dict[str, Any] | None = None,
    **_: Any,
) -> FeatureCollection:
    """Reads a whole JSON-FG document.

    Args:
        source: The document to read.
        context: Pydantic validation context for the collection. Setting
            `DROP_INVALID_REFS` in it makes unresolvable and external references be
            dropped rather than raise, and the dropped `InvalidReference`s are
            collected in the same dict under `INVALID_REFS` for the caller to read
            back.
        **_: Options for another encoding, ignored here.

    Raises:
        JsonFGParseError: The document is not readable JSON-FG.
        AppschemaNotFoundError: No supported application schema was declared.

    Returns:
        The collection the document holds.
    """
    content = _parse(source)
    appschema = _appschema_from_links(content)
    srs = parse_srs(content.get("coordRefSys", None))
    _settle_ids(content, appschema)

    collection = {}
    for feature in content["features"]:
        if not srs.srid:
            # an undeclared JSON-FG CRS is CRS84 - lon/lat, the order a geometry is
            # stored in, so naming it that way orders no swap
            srs = parse_srs(feature.get("coordRefSys", "OGC:CRS84"))
        model = decode_feature(
            appschema.model_factory(_featuretype(feature, content), FeatureType),
            feature,
            srs,
        )
        collection[model.id] = model
    return FeatureCollection.from_features(
        collection, srs.srid, appschema, context=context
    )

decode_feature

decode_feature(cls: type[F], feature: dict[str, Any], srs: SRS) -> F

Decodes one JSON-FG feature object as cls.

Parameters:

Name Type Description Default
cls type[F]

The feature type the object holds.

required
feature dict[str, Any]

The feature object.

required
srs SRS

The document's SRS, for a place whose feature declares none.

required

Returns:

Type Description
F

The validated feature.

Source code in xmas_core/codec/jsonfg.py
def decode_feature[F: FeatureType](
    cls: type[F], feature: dict[str, Any], srs: SRS
) -> F:
    """Decodes one JSON-FG feature object as `cls`.

    Args:
        cls: The feature type the object holds.
        feature: The feature object.
        srs: The document's SRS, for a ``place`` whose feature declares none.

    Returns:
        The validated feature.
    """
    # `properties` is null for a feature carrying none of its own - a presentation
    # object's geometry travels in `place` - and `id` is read the same way so that
    # validating a document's feature directly reports the missing id as the model's
    # own error rather than as a KeyError from here
    data = (feature.get("properties") or {}) | {"id": feature.get("id")}
    if geom := feature.get("place"):
        ogr_geometry = ogr.CreateGeometryFromJson(to_json(geom).decode())
        # a JSON-FG 1.0 document declares the CRS on its root object only; a pre-1.0
        # one put it on the feature
        if raw := feature.get("coordRefSys", None):
            srs = parse_srs(raw)
        if srs.swap_axes:
            # recurses into curve members
            ogr_geometry.SwapXY()
        data[cls.get_geom_field()] = {
            "srid": srs.srid,
            "wkt": ogr_geometry.ExportToWkt(),
        }
    elif geom := feature.get("geometry"):
        ogr_geometry = ogr.CreateGeometryFromJson(to_json(geom).decode())
        data[cls.get_geom_field()] = {
            "srid": 4326,
            "wkt": ogr_geometry.ExportToWkt(),
        }
    return cls.model_validate(data)

encode

encode(collection: FeatureCollection, target: Target, *, single_collection: bool = True, write_geometry: bool = True, write_bbox: bool = True, **_: Any) -> None

Writes a collection as one JSON-FG document, or one per feature type.

Parameters:

Name Type Description Default
collection FeatureCollection

The collection to write.

required
target Target

Where to write it. With single_collection=False it names the family rather than a single document, so it has to be a file path.

required
single_collection bool

Write everything as one document. False writes one document per feature type, each beside the target with the type appended to its stem.

True
write_geometry bool True
write_bbox bool True
**_ Any

Options for another encoding, ignored here.

{}

Raises:

Type Description
DatasourceError

A fan-out was asked for and the target is not a file path.

Source code in xmas_core/codec/jsonfg.py
def encode(
    collection: FeatureCollection,
    target: Target,
    *,
    single_collection: bool = True,
    write_geometry: bool = True,
    write_bbox: bool = True,
    **_: Any,
) -> None:
    """Writes a collection as one JSON-FG document, or one per feature type.

    Args:
        collection: The collection to write.
        target: Where to write it. With `single_collection=False` it names the family
            rather than a single document, so it has to be a file path.
        single_collection: Write everything as one document. `False` writes one
            document per feature type, each beside the target with the type appended
            to its stem.
        write_geometry: See [`encode_feature`][xmas_core.codec.jsonfg.encode_feature].
        write_bbox: See [`encode_feature`][xmas_core.codec.jsonfg.encode_feature].
        **_: Options for another encoding, ignored here.

    Raises:
        DatasourceError: A fan-out was asked for and the target is not a file path.
    """
    appschema = collection.appschema
    options = {"write_geometry": write_geometry, "write_bbox": write_bbox}
    if single_collection:
        document = _collection_template(appschema, collection.srid)
        document["features"].extend(
            encode_feature(feature, **options)
            for feature in collection.get_features()
            if feature
        )
        _write(document, target)
        return

    # one document per feature type, so the target names a family of files rather than
    # a single one - which only a path can do
    featuretypes: dict[str, list[FeatureType]] = {}
    for feature in collection.get_features():
        featuretypes.setdefault(feature.get_name(), []).append(feature)
    for featuretype, typed_features in featuretypes.items():
        document = _collection_template(appschema, collection.srid, featuretype)
        document["features"].extend(
            encode_feature(feature, **options, write_featuretype=False)
            for feature in typed_features
        )
        _write(document, target.sibling(featuretype))

encode_feature

encode_feature(feature: FeatureType, *, write_featuretype: bool = True, write_geometry: bool = True, write_bbox: bool = True) -> dict[str, Any]

Encodes one feature as a JSON-FG feature object.

Parameters:

Name Type Description Default
feature FeatureType

The feature to encode.

required
write_featuretype bool

Write featureType; a document holding one feature type declares it on the collection instead.

True
write_geometry bool

Write the WGS 84 geometry beside place. It is always written when there is no place.

True
write_bbox bool

Write bbox with geometry.

True

Returns:

Type Description
dict[str, Any]

The feature object.

Source code in xmas_core/codec/jsonfg.py
def encode_feature(
    feature: FeatureType,
    *,
    write_featuretype: bool = True,
    write_geometry: bool = True,
    write_bbox: bool = True,
) -> dict[str, Any]:
    """Encodes one feature as a JSON-FG feature object.

    Args:
        feature: The feature to encode.
        write_featuretype: Write ``featureType``; a document holding one feature type
            declares it on the collection instead.
        write_geometry: Write the WGS 84 ``geometry`` beside ``place``. It is always
            written when there is no ``place``.
        write_bbox: Write ``bbox`` with ``geometry``.

    Returns:
        The feature object.
    """
    properties = feature.model_dump(mode="json", exclude={"id"}, exclude_none=True)
    geom_field = feature.get_geom_field()
    geometry = properties.pop(geom_field, None) if geom_field else None
    data: dict[str, Any] = {
        "type": "Feature",
        "id": feature.id,
        "featureType": feature.get_name(),
        "properties": properties,
        "geometry": None,
        "place": None,
    }
    if not write_featuretype:
        data.pop("featureType")
    if geometry is not None:
        srid = feature.get_geom_srid()
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(srid)
        # the stored eWKT is x/y - easting/longitude first - whatever the authority orders
        srs.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
        ogr_geom = ogr.CreateGeometryFromWkt(feature.get_geom_wkt())
        ogr_geom.AssignSpatialReference(srs)
        # `place` carries the geometry as authored, arcs included, in the CRS's own axis
        # order. A linear geometry in WGS 84 lon/lat is forbidden there (JSON-FG 1.0
        # req 11) and `geometry` already holds it, so at 4326 only a curve earns a place.
        if srid != 4326 or ogr_geom.HasCurveGeometry():
            data["place"] = from_json(
                ogr_geom.ExportToJson(
                    options=[
                        "ALLOW_CURVE=YES",
                        "COORDINATE_ORDER=AUTHORITY_COMPLIANT",
                    ]
                )
            )
        # `geometry` is always WGS 84 lon/lat and always linear; it is the only carrier
        # when there is no `place`, so `write_geometry` cannot drop it then.
        if data["place"] is None or write_geometry:
            wgs_geom = ogr_geom.GetLinearGeometry()
            if srid != 4326:
                wgs = osr.SpatialReference()
                wgs.ImportFromEPSG(4326)
                wgs.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
                wgs_geom.TransformTo(wgs)
            data["geometry"] = from_json(wgs_geom.ExportToJson())
            if write_bbox:
                min_x, max_x, min_y, max_y = wgs_geom.GetEnvelope()
                data["bbox"] = (min_x, min_y, max_x, max_y)
    return data